Compare commits

...

239 commits

Author SHA1 Message Date
roo hutton
1741e48d59 Merge pull request #24619 from overleaf/rh-team-invites-index
Add migration for subscriptions.teamInvites.email index

GitOrigin-RevId: 5f4bca578ae0dcf92c422596aa7834c42dc63bee
2025-04-09 08:05:49 +00:00
Jimmy Domagala-Tang
94a067d7c8 Merge pull request #24208 from overleaf/jdt-wf-analytics-wrapper
Create listener for Writefull to send events to our analytics

GitOrigin-RevId: 8ad1866e3c81b1f6c3388b744f9e27810623436e
2025-04-09 08:05:42 +00:00
Jakob Ackermann
3f10b29869 [real-time] backwards compatibility fix for redis 6.2 (#24734)
GitOrigin-RevId: ffc51a8280e0d0708e7dcb2638cabed2b7adfbf5
2025-04-09 08:05:37 +00:00
Jakob Ackermann
ca111771c2 [real-time] rename metric label to align with common name (#24732)
GitOrigin-RevId: fd161f2345f0c30aa82395dbca673025698f13fe
2025-04-09 08:05:33 +00:00
Jakob Ackermann
dadf6f0ed4 [real-time] record metrics on number of connected clients per project (#24727)
Note: "connected" here means across all real-time pods.

- editing_session_mode, counter
  - mode=connect - a client connected
  - mode=disconnect - a client disconnect
  - mode=update - continuous editing
  - status=empty - all clients disconnected
  - status=single - a single client is connected
  - status=multi - multiple clients are connected

- project_not_empty_since histogram with buckets [0,1h,2h,1d,2d,1w,30d]
  - status=empty/single/multi as described above

GitOrigin-RevId: 1cc42e72bbb5aae754399bdbc3f8771642f35c22
2025-04-09 08:05:29 +00:00
Jessica Lawshe
fc56d1690d Merge pull request #24617 from overleaf/jel-fix-status-labels
[web] Switch to `OLBadge` to fix style in BS5 on admin SSO config labels

GitOrigin-RevId: eb3745b602f33e9bd5aea3704ec6f0d2904ee5b1
2025-04-09 08:05:25 +00:00
Jessica Lawshe
aa723a70c2 Merge pull request #24580 from overleaf/jel-bs5-loading-labels
[web] Add accessibility labels for processing view on BS5 group buttons

GitOrigin-RevId: bb79d3b73eb187097d036bc5a6e307c4232f32d0
2025-04-09 08:05:21 +00:00
M Fahru
73e141a4a3 Merge pull request #24635 from overleaf/mf-tear-down-period-toggle-improvements-test
[web] Tear down `period-toggle-improvements` split test and apply the `default` variant

GitOrigin-RevId: 154a291437afc6e4b1c87eef91e6f05ae5a454c3
2025-04-09 08:05:04 +00:00
M Fahru
620edfa347 Merge pull request #24632 from overleaf/mf-tear-down-group-tab-improvements-split-test
[web] Tear down `group-tab-improvements` split test and apply the `default` variant

GitOrigin-RevId: c2fe07d0b4338f85b053637d85a05bbcbcce74ea
2025-04-09 08:05:00 +00:00
M Fahru
4e192f760d Merge pull request #24458 from overleaf/mf-update-rocket-yellow-to-be-bigger
[web] Update rocket-yellow sticker size

GitOrigin-RevId: 0e5b39610687eff3d2ce51e48da2e7829f26f574
2025-04-09 08:04:55 +00:00
Miguel Serrano
44926d3519 Merge pull request #24721 from overleaf/msm-cleanup-git-oauth-secret
[git-bridge] Cleanup `oauth` clientID/secret

GitOrigin-RevId: 48144d928119782d1c7b048b0cb6a4afb6072f28
2025-04-09 08:04:48 +00:00
Tim Down
b41f8164b8 Merge pull request #24709 from overleaf/td-prevent-spellcheck-after-destroy
Prevent spell checks after spell checker is destroyed

GitOrigin-RevId: 070f6c6ed05063e46960dad8099d61f585d6120c
2025-04-08 08:07:44 +00:00
Eric Mc Sween
bfd9ab6b8f Merge pull request #24604 from overleaf/em-docstore-errors
Downgrade 4xx errors in docstore

GitOrigin-RevId: ec6c73b4222876e6f58690779571e2e42106c36b
2025-04-08 08:07:34 +00:00
Tim Down
9ea0f2ec29 Merge pull request #24606 from overleaf/td-bs5-make-default
Make Bootstrap 5 the default everywhere

GitOrigin-RevId: 024614d6f4f370fd9c9623a6f35c64e43d2a70c4
2025-04-08 08:07:29 +00:00
Tim Down
4cee376878 Merge pull request #24688 from overleaf/td-bs5-editor-beginner-switch-popover
Migrate beginner editor switch popover to BS5

GitOrigin-RevId: c470df46989de7ad6477ee23ff13fc95dd580ea8
2025-04-08 08:07:24 +00:00
ilkin-overleaf
4e8f982ca2 Merge pull request #24682 from overleaf/ii-group-members-table-2
[web] Group members table colspan fix 2

GitOrigin-RevId: ddb7438da3c68b74b8f38feb8512175e8c24443d
2025-04-08 08:07:19 +00:00
Tim Down
eb276c7403 Merge pull request #24416 from overleaf/td-bs5-remove-platform-pages-flag
Remove bs5-misc-pages-platform feature flag from code

GitOrigin-RevId: 8da617e5d7703a56399b227b0c38acda86150b8d
2025-04-08 08:07:07 +00:00
Brian Gough
a68e96400b Merge pull request #24670 from overleaf/bg-remove-logging-of-update-errors
remove update parameter in applyOtUpdate error handling

GitOrigin-RevId: 46fa9d669327850f956154b20676317a7b13eb78
2025-04-08 08:06:46 +00:00
Domagoj Kriskovic
0f3f78cde7 Add mouse down listener in PreventSelectingEntry (#24665)
GitOrigin-RevId: 97411fd45d10b850f41c3f6269550bc6fffb0e11
2025-04-08 08:06:41 +00:00
MoxAmber
d08fa01110 Merge pull request #24545 from overleaf/as-customerio-toolbar-placeholders
[web] Create placeholders for customer.io inline messages

GitOrigin-RevId: 862362cd9336e5c1899dfaeeabac9f3da181ccf9
2025-04-08 08:06:28 +00:00
Jakob Ackermann
cdf04d695c [clsi] upgrade dockerode to bump tar-fs (#24693)
Diff: https://github.com/apocas/dockerode/compare/v3.3.1...v4.0.5
GitOrigin-RevId: 73ba2610d0c2e766a52e638754af410aaad94ec1
2025-04-08 08:06:23 +00:00
MoxAmber
71bc4c45bc Merge pull request #24373 from overleaf/as-customerio-web-sdk
[web] Set up customerio frontend SDK

GitOrigin-RevId: 0e043163e1f6cd02d8ecf3a3e854e7799d776edd
2025-04-08 08:06:18 +00:00
David
55a13ca1de Merge pull request #24662 from overleaf/mj-wc-survey-cleanup
[web] Remove unused component

GitOrigin-RevId: dd525258349834a8bbb28e78a06445bafc9b2e99
2025-04-08 08:06:09 +00:00
David
92dd62975e Merge pull request #24575 from overleaf/dp-review-panel-dark-theme
Add dark theme for review panel in new editor

GitOrigin-RevId: 179cc257cd66f1ac477d7f4d428992019298ebc1
2025-04-08 08:06:04 +00:00
Brian Gough
040f70471c Merge pull request #24636 from overleaf/bg-history-backup-fix-broken-projects
add --fix option to backup script

GitOrigin-RevId: 568c9158669bb1cede0f0dd75e7507b10e8ff5a2
2025-04-08 08:05:59 +00:00
Brian Gough
f08532dfb0 Merge pull request #24637 from overleaf/bg-history-backup-uninitialised-projects
backup uninitialised projects

GitOrigin-RevId: 9310ef9f803decffbd674024a1ffd33d1960a2c4
2025-04-08 08:05:54 +00:00
Antoine Clausse
7eecfe9e27 [web] Add another partial fix for fix_malformed_filetree: use _id instead of path to locate data (#24101)
* Fix `fix_malformed_filetree`'s `fixName`

* Fix findUniqueName with missing names in siblings

* Add test showcasing another bug: shifted arrays in filetree folder

* Update `removeNulls` to use `_id`

* Update services/web/app/src/Features/Project/ProjectLocator.js

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Add FIXME about file names uniqueness

* Rename `obj` to `project`

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 3ed795ae0621800603395f7b50626ac89c39199d
2025-04-08 08:05:32 +00:00
CloudBuild
670ed44963 auto update translation
GitOrigin-RevId: 2a2199f74cf1e0c2506ba336624cd858e2f24d3e
2025-04-07 08:05:15 +00:00
CloudBuild
f2030789d1 auto update translation
GitOrigin-RevId: 6363d21d4903cb8f4cdcca28ec1b1baca39406b1
2025-04-07 08:05:11 +00:00
ilkin-overleaf
ee11889431 Merge pull request #24647 from overleaf/ii-group-members-table
[web] Group members table colspan fix

GitOrigin-RevId: 99d52f0081ef500f63d86d9bdc2fda5c3cdab1d9
2025-04-07 08:05:04 +00:00
Domagoj Kriskovic
1fb18b092d Add upgrade prompt for track changes in share modal (#24572)
* Add upgrade prompt for track changes in share modal

* remove message from invite.jsx

* Fix itemToDisabled in Select

GitOrigin-RevId: 5ba9e2b063c7e26a4c39b9e973eddce36a5b4733
2025-04-07 08:05:00 +00:00
Domagoj Kriskovic
6169a5d3df Update track changes paywall modal (#24620)
* Update track changes paywall modal

* update list styling

GitOrigin-RevId: f5eda3a4b19c89105e163c8b5729ebcdd5dca2d0
2025-04-07 08:04:56 +00:00
Domagoj Kriskovic
51250ca45f Add review access notification in Git bridge modal (#24623)
GitOrigin-RevId: e9efc2f036445f610f2c1aa60a882faf09d2067f
2025-04-07 08:04:52 +00:00
Domagoj Kriskovic
bfe5871e9e Dropbox sync info message for read-only and reviewer collaborators (#24641)
* Dropbox sync info message for read-only and reviewer collaborators

* fix translation text

GitOrigin-RevId: 12984a1f9fa20c39f171b56f4a46830df7a5f5e0
2025-04-07 08:04:48 +00:00
Brian Gough
0cc244c516 Merge pull request #20022 from overleaf/bg-check-file-tree
add script to check for errors in project file tree

GitOrigin-RevId: da115cbd79e7ca53a0222638a54bbea1b633f709
2025-04-07 08:04:44 +00:00
Mathias Jakobsen
767ac1632e Merge pull request #24427 from overleaf/mj-command-registry
[web] Editor redesign: Add command registry

GitOrigin-RevId: c3d78d052f7e6e067de3247da8fe04329d8822ff
2025-04-07 08:04:40 +00:00
M Fahru
1d1bad23e3 Merge pull request #24625 from overleaf/mf-fix-error-auth-pages-bs5
[web] Fix form message errors aren't shown properly in bs5 auth pages

GitOrigin-RevId: 9a94fe53647d224faf63bdd047bfa26463d385f1
2025-04-04 08:05:18 +00:00
M Fahru
73b4584575 Merge pull request #24384 from overleaf/mf-teardown-checkout-redesign-split-test
[web] Tear down `checkout-redesign` split test

GitOrigin-RevId: b3038276c28aece85a47d7b0a8134fad75e8af2c
2025-04-04 08:05:13 +00:00
Tim Down
bbf85ae6d2 Merge pull request #24460 from overleaf/ar-personal-access-token-add-type-to-index
[web] personal access token add type to index

GitOrigin-RevId: 28b0fb8d3764c977d667cd8a5ee543d1f2e2eed2
2025-04-04 08:05:09 +00:00
Brian Gough
90fac6b206 Merge pull request #24587 from overleaf/bg-fix-web-routes-script
fix bin/routes script

GitOrigin-RevId: bc791cf01ce3321ec4badffe2cbc8c4ea93ba381
2025-04-04 08:05:04 +00:00
Brian Gough
9f527f10e1 Merge pull request #24104 from overleaf/bg-batched-update-concurrency-warning
prevent concurrent execution of batchedUpdate

GitOrigin-RevId: 90853ccd83943d8cd7b01fd11f152512d806e9a7
2025-04-04 08:05:00 +00:00
roo hutton
fc6df69e41 Merge pull request #24630 from overleaf/rh-null-editor-fix
Coerce null reviewer_refs to empty array

GitOrigin-RevId: dd1931b306e22fc4b7dbd3709dfac786a2475724
2025-04-04 08:04:44 +00:00
ilkin-overleaf
3b5a148cdc Merge pull request #24634 from overleaf/ii-flexible-group-licensing-replace-add-with-purchase
[web] Replace "add" with "buy" in FL

GitOrigin-RevId: 2c9aa50f138306a46ebfd8557d907c6c55d694bc
2025-04-03 08:05:12 +00:00
Domagoj Kriskovic
4e27add5b7 Add AI assist subscription check in WritefullManager (#24428)
* Add AI assist subscription check when updating subscription in WritefullManager

* move AI assist check in WritefullController

GitOrigin-RevId: 0327e1c19e14e554fb707dc1d2840b7147bdf1d1
2025-04-03 08:05:08 +00:00
Domagoj Kriskovic
c3e34f8850 Rename editMode to mode in "project-opened" analytics event (#24552)
GitOrigin-RevId: f04d5c3550bed14e9f34067fef5b8e46e56e6c4d
2025-04-03 08:05:03 +00:00
Domagoj Kriskovic
f36c87b301 Update share modal dropdown with a description for reviewers in a free project (#24571)
* Update collaborator select dropdown with a description for reviewers in free project

* Update share-project-modal test

* Fix saas-e2e tests

* fix server pro tests

* fix cypress multiple inputs selection

* fix testid case

GitOrigin-RevId: 5369828334596d80988aba168385f0a51eea998f
2025-04-03 08:04:59 +00:00
Domagoj Kriskovic
830d0daa38 Script for removing user enrollment from a subscription (#24627)
* Script for removing user enrollment to a subcription

* Add "release-managed-user"  audit log event for an user id

GitOrigin-RevId: adf2dd97ac82977bcfa07d9a24d1f3c190d095a2
2025-04-03 08:04:54 +00:00
Miguel Serrano
fd62142b21 Merge pull request #24397 from overleaf/msm-saml-logs-indexes
[web] Add missing indexes for SAML Logs search

GitOrigin-RevId: 07d994fc6a1b2af7a86e933d0b0c4d4c76370801
2025-04-03 08:04:49 +00:00
Miguel Serrano
6501314616 Merge pull request #24445 from overleaf/msm-saml-identifiers-migration
[web] Update user.samlIdentifiers compound index

GitOrigin-RevId: f14da17fa89c89f0fc75df074b3f6ca04b479efb
2025-04-03 08:04:45 +00:00
ilkin-overleaf
fef5ab7255 Merge pull request #24562 from overleaf/ii-bs5-group-settings-tear-down
[web] Group settings BS5 feature flag tear down

GitOrigin-RevId: c83297771dfeeb0426096b3b0765d6c420356d2a
2025-04-02 08:05:14 +00:00
roo hutton
0c6c61b654 Merge pull request #24618 from overleaf/rh-null-pending-reviewer
Handle null readOnly_refs in CollaboratorsHandler

GitOrigin-RevId: b718c4a1b5216a42920909615a0f3c2fe0a16bdb
2025-04-02 08:05:10 +00:00
roo hutton
774292d8ba Merge pull request #24609 from overleaf/rh-bs5-b2c-teardown
Teardown Bootstrap 5 B2C feature flag

GitOrigin-RevId: 4021820bc3b207dae9fffd23dfac778746fa8f20
2025-04-02 08:05:05 +00:00
Brian Gough
6969a12fae Merge pull request #24616 from overleaf/bg-npm-audit-fix
update axios to version 1.8.4

GitOrigin-RevId: 0e7deb1b55b08ad1c1d81fdd2fa96925b514caf7
2025-04-02 08:04:59 +00:00
roo hutton
c52b23af57 Merge pull request #24614 from overleaf/rh-pending-reviewer-pull
Set null pendingReviewer_refs to empty array in removeUserFromProject

GitOrigin-RevId: b21dd3f2febdb59dfafb45347960e375daeac463
2025-04-01 08:04:42 +00:00
CloudBuild
e08e58485e auto update translation
GitOrigin-RevId: 034ad5d613a28540d777f88c75e7284bc7eb7c3e
2025-03-31 08:05:01 +00:00
ilkin-overleaf
b9bc2b01fd Merge pull request #24597 from overleaf/ii-flexible-group-licensing-replace-users-with-licenses-2
[web] FL "licenses" in cost summary preview

GitOrigin-RevId: f6020fe0c5903dc2b1ebb923718525403e5a2fe7
2025-03-31 08:04:57 +00:00
ilkin-overleaf
b223bb8da8 Merge pull request #24576 from overleaf/ii-flexible-group-licensing-replace-users-with-licenses
[web] Replace "users" with "licenses" in flexible licensing

GitOrigin-RevId: c262f802c92e7ce82f8dc60badf8e6147c5da4e5
2025-03-31 08:04:50 +00:00
ilkin-overleaf
61ce012fb5 Merge pull request #24430 from overleaf/ii-flexible-group-licensing-tear-down-feature-flag
[web] Tear down flexible group licensing feature flag

GitOrigin-RevId: 181713f1f2371b51cbc4256debea59bbcf3668f3
2025-03-31 08:04:42 +00:00
M Fahru
f0fe0db10c [web] Remove unused get_more_compile_time string key because of paywall-cta split test is removed (#24574)
* Re-add missing translation key

* Remove `get_more_compile_time` translation key because of `paywall-cta` is removed

GitOrigin-RevId: bf0fe677adc32a91db57d41d83f0f1e0b2ee3741
2025-03-28 09:04:41 +00:00
Andrew Rumble
93793fe723 Increase default value
GitOrigin-RevId: 6674716042415aaa6b73b4efcf46443c3faab0cc
2025-03-27 14:17:15 +00:00
Andrew Rumble
46f3b595a9 Configure backup RPO for backup-verifier
GitOrigin-RevId: 376c2f18a1e9bef4e77f0ce5999ec7ce5341d481
2025-03-27 14:17:11 +00:00
M Fahru
639690bb50 Merge pull request #24551 from overleaf/mf-remove-fedramp-plans-page
[web] Remove fedramp from group tab in plans page features table

GitOrigin-RevId: fd855ad6ce7bb0d14e970342740a93d4f85ae465
2025-03-27 14:17:07 +00:00
M Fahru
90b6dbdf55 Merge pull request #24530 from overleaf/mf-add-papers-integration-plans-page
[web] Add Papers integration to plans page

GitOrigin-RevId: 72a890fb69417d5a9e8f4146421a3a2ee1716c32
2025-03-27 14:17:03 +00:00
M Fahru
640c699042 Merge pull request #24525 from overleaf/mf-add-learning-menu-navigation
[web] Add new "Resources" menu in our "Help" menu dropdown

GitOrigin-RevId: 9a0e26696d33edaba091abf8fac97fbf051bb6c2
2025-03-27 14:16:59 +00:00
M Fahru
ad677afa81 Merge pull request #22705 from overleaf/mf-tear-down-paywall-cta
[web] Tear down `paywall-cta` split test

GitOrigin-RevId: 8cfba819b80bb34f48f8fc1fec37aee6791ef9f0
2025-03-27 14:16:52 +00:00
Eric Mc Sween
f46fd6f2d5 Merge pull request #24433 from overleaf/em-pending-reviewers
Support reviewers in the collaborator limit enforcement logic

GitOrigin-RevId: f11a8e37ca6ef36f9894233803c6ee8363bf0ff8
2025-03-27 14:16:48 +00:00
Miguel Serrano
373b222929 Merge pull request #24565 from overleaf/msm-fix-ldap-test
[CE/SP] Fix LDAP e2e tests

GitOrigin-RevId: 536de71f2b4e5b9bd2ae67dbea26d03b794a7a64
2025-03-27 14:16:44 +00:00
Andrew Rumble
99ab41fd76 Allow scaling in getEndDateForRPO
RPO can now be scaled to allow a little extra grace in certain
circumstances.

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: fa60a9ffe966977e396b5645919ddd1451fb1b7a
2025-03-27 14:16:40 +00:00
Andrew Rumble
c921c8f586 Monitor backup status in health check
GitOrigin-RevId: 1a90b2fcf85bfa5ec1e5aabcfe4183d406da95e6
2025-03-27 14:16:36 +00:00
Andrew Rumble
4707842642 Add functions for monitoring backup status of db records
GitOrigin-RevId: f0d3564dfd3b38ef9ee451de866c0c05acd0cb20
2025-03-27 14:16:32 +00:00
Andrew Rumble
dabf610764 Extract getEndDateForRPO method to utils
This will allow sharing with other functionality.

GitOrigin-RevId: a6e11447180511cc3741ca03f4996ef7ceb45ea5
2025-03-27 14:16:28 +00:00
Miguel Serrano
b44dd4d3c5 Merge pull request #24558 from overleaf/msm-ce-rebuild-dockerfile
[CE] Rebuild Dockerfile-base

GitOrigin-RevId: 7fca7fc64a709f7d5d4eccb4b7f9586a78769803
2025-03-27 14:16:17 +00:00
Tim Down
2d4b5e51f1 Merge pull request #24539 from overleaf/td-inactive-project-index-2
Update inactive project deactivation index, second attempt

GitOrigin-RevId: adc16761b001b2aa919b5a61cdbd468b7ad4e26d
2025-03-27 14:16:07 +00:00
Andrew Rumble
c55df0e803 Add new index to improve sort speed on hostname search
GitOrigin-RevId: e78c2848a0cfbe6347e57de140512941a68f7f47
2025-03-27 14:15:59 +00:00
Alf Eaton
b14490d6aa Avoid setting pendingOpCreatedAt for remote changes (#24542)
GitOrigin-RevId: 5d1684a743825cac23d486678b8f249cf0d152d8
2025-03-27 09:06:02 +00:00
David
d67f3d3181 Merge pull request #24442 from overleaf/dp-writefull-parent
Provide writefull with element selectors to insert into

GitOrigin-RevId: 87c9a939f7024e54adfe0b970dddb96b7f56d6b3
2025-03-27 09:05:51 +00:00
Antoine Clausse
2f0254a2c8 [web] Add reCAPTCHA conditions to /user/emails/add-secondary (#24528)
* Add RecaptchaConditions to `user/emails/add-secondary`, set font to 12px

* Hide `RecaptchaConditions` if `ExposedSettings.recaptchaDisabled.addEmail`

GitOrigin-RevId: dcc4609bf8787076257caed6b5a5d1e47178380e
2025-03-27 09:05:47 +00:00
Antoine Clausse
fa058a5ca8 Add recaptcha conditions to the add-email form (#24295)
GitOrigin-RevId: 4b90a73edd2435d6c858ccf790827fb3352069e8
2025-03-27 09:05:42 +00:00
Tim Down
25d1b2a24d Merge pull request #24448 from overleaf/td-deleted-users-email-index
Add user email indexes on deletedUsers collection

GitOrigin-RevId: 032b8b189b67b9f491b47438c3b2e0cbfd7b8294
2025-03-27 09:05:38 +00:00
Tim Down
363e426e9f Merge pull request #24536 from overleaf/revert-24444-td-inactive-project-index
Revert "Update inactive project deactivation index"

GitOrigin-RevId: cc6788af5fcb652fdb1eaeefe7f10c5bc1afa910
2025-03-27 09:05:34 +00:00
Tim Down
d04cc1d8ac Update inactive project deactivation index (#24444)
* Update inactive project deactivation index

* Delete index first, otherwise Mongo complains that the index already exists with a different name

GitOrigin-RevId: b28a60c0774a77363c4e052ddb64bf68665cccf5
2025-03-27 09:05:22 +00:00
Jakob Ackermann
dda94cdfbc [web] ensure that only a single socket.io transport is connected (#24422)
GitOrigin-RevId: 9397b0c85f0a889385d4761945e976ada7aa537b
2025-03-27 09:05:14 +00:00
Alf Eaton
e754ee9cb4 Remove unused flush-changes listener (#24449)
GitOrigin-RevId: e22a6677617e1d20f9cd4fce38653d126714a553
2025-03-27 09:05:06 +00:00
Andrew Rumble
7eb5c8a38e Adds deleted filter to docs index
GitOrigin-RevId: bd00fa383946c3a2aa7b03f355aad399bbe9cdca
2025-03-27 09:05:01 +00:00
Miguel Serrano
1ab8302254 Merge pull request #24467 from overleaf/msm-fix-sp-e2e
[CE/SP] e2e fixes

GitOrigin-RevId: 32722dd7d7520e87cd8d8cf6dbdadec73b27caa6
2025-03-26 09:05:36 +00:00
roo hutton
29238e54e3 Merge pull request #24125 from overleaf/rh-current-version-fix
Fix version check for undefined split tests

GitOrigin-RevId: bbbd44a27ca41c5ffac02cb34ac0049a14cd13e4
2025-03-26 09:05:32 +00:00
Eric Mc Sween
20c7f14b3c Merge pull request #24461 from overleaf/em-non-editable-file-in-history
Graceful fallback for getRangesSnapshot()

GitOrigin-RevId: 2f0e70dbcdd38de87d0b1c7590f7d8a5a7112937
2025-03-26 09:05:21 +00:00
Miguel Serrano
b480903426 Merge pull request #24381 from overleaf/msm-improve-sync-group-subscription-script
[web] Improve group subscription sync + add missing indexes

GitOrigin-RevId: e7cecd9b8a1978a9e13a165d3f646b98ff7e9394
2025-03-26 09:05:04 +00:00
Miguel Serrano
235f1a5a59 Merge pull request #24329 from overleaf/msm-fix-login-translation-ce-sp
[web] Fix `log_in` default translation in user/pass login

GitOrigin-RevId: 70876652a462b7917d9864e5dccce45ebf3b6d0a
2025-03-26 09:05:00 +00:00
Miguel Serrano
d59afb21be Merge pull request #24327 from overleaf/msm-update-max-reconnect-gracefully-interval
[web] Make `maxReconnectGracefullyIntervalMs` configurable

GitOrigin-RevId: 18846b050e76ec86d00bc0dd7e07ab09d552bed7
2025-03-26 09:04:56 +00:00
Jimmy Domagala-Tang
5ba43eb56c Merge pull request #24253 from overleaf/jdt-move-wf-instance-to-context
moving WF to editor context in anticipation for calling functionality…

GitOrigin-RevId: 368554e9308fe8e9e7ef42a4abd78690ec834520
2025-03-25 09:05:21 +00:00
M Fahru
9eb84d6ad5 Merge pull request #24417 from overleaf/mf-implement-stripe-hosted-checkout-split-test
[web] Implement stripe hosted checkout with split test

GitOrigin-RevId: 25e5ff2a46135f402cdf479623ab38c858c5640c
2025-03-25 09:05:16 +00:00
Eric Mc Sween
374acf8119 Merge pull request #24462 from overleaf/em-resync-error
Fix error reporting on resyncs in project-history

GitOrigin-RevId: 9e8a454f746c2b4db7c28fafcd52e50935af5957
2025-03-25 09:05:12 +00:00
CloudBuild
ab19677a6c auto update translation
GitOrigin-RevId: 9ba894868c164a34d5420369c798d837a06fd450
2025-03-24 10:51:34 +00:00
CloudBuild
e8462f4250 auto update translation
GitOrigin-RevId: d8edcccce86a3892bebf2b4ab2d769275a231325
2025-03-24 10:51:30 +00:00
Eric Mc Sween
1fb94dee18 Merge pull request #24357 from overleaf/em-reviewers-limit
Count reviewers towards the collaborator limit in the backend

GitOrigin-RevId: 352fa37023ffbb032e23a474a81897ca871fee69
2025-03-24 10:51:14 +00:00
Eric Mc Sween
e4dae982d2 Merge pull request #24225 from overleaf/em-reviewers-share-modal
Count reviewers towards collaborator limit in share modal

GitOrigin-RevId: 27ec3a787124be7590791412d914ec6da78bab35
2025-03-24 10:51:09 +00:00
Thomas Mees
f5c92cb627 Merge pull request #24309 from overleaf/tm-recurly-webhook-ratelimit
Make a new less restrictive ratelimiter for the recurly callback

GitOrigin-RevId: 61bc39110a7ecc6e8f937478a9ccc965b555add5
2025-03-24 10:51:05 +00:00
Alf Eaton
bdcf1d3a83 Convert GitHub Sync tests to Cypress (#24228)
* Use OError
* Remove setTimeout stub
* Convert GitHub Sync tests to Cypress
* Use setIgnoringExternalUpdates directly
* Migrate remaining GitHub Sync components to TypeScript

GitOrigin-RevId: 7c8b875b9a7bbf6353d87a5f93c2267d1d4bc65d
2025-03-24 10:51:00 +00:00
Alf Eaton
e827540a6d Record an event when the "unsaved doc" alert is displayed (#24431)
GitOrigin-RevId: 87ccdf5a15173afa7548532d72fb1d3e32f255fe
2025-03-24 10:50:56 +00:00
Alf Eaton
7f019d3880 Fix id and labels for compile/image settings (#24439)
GitOrigin-RevId: 5a686afed947bf2da1f90d80f20cb8ba7168a1f5
2025-03-24 10:50:51 +00:00
Miguel Serrano
716fe07e84 Merge pull request #24383 from overleaf/msm-hotfix-5-3-3
[SP/CE] Hotfix 5.3.3 / 4.2.9

GitOrigin-RevId: e377fe7ab83295d0a270f908bde4628928a76775
2025-03-24 10:50:43 +00:00
Eric Mc Sween
0e9c310d1d Merge pull request #24390 from overleaf/em-enforce-content-hash-validation
Enforce content hash validation in history

GitOrigin-RevId: 90de21ea86ddc6548001059c41139a2af5b27060
2025-03-24 10:50:01 +00:00
David
7308ac0e1f Merge pull request #24306 from overleaf/mj-ide-breadcrumbs-loading
[web] Disable breadcrumbs with user setting

GitOrigin-RevId: bcb43aee139a321d9532b9a49e1e73fabba2fd66
2025-03-24 10:49:46 +00:00
David
a853a92765 Merge pull request #24350 from overleaf/dp-settings-modal
Implement new editor settings modal options

GitOrigin-RevId: b16358e29f2ebcac6e7f92f1c0196f5911f2e733
2025-03-24 10:49:41 +00:00
Rebeka Dekany
562ef81389 Cleanup Bootstrap 3 code in the Account settings page (#24058)
* Remove the Bootstrap 5 version utilities

* Remove Account settings LESS stylesheet and unused styles

* Prefer using the OLFormText wrapper component instead of FormText

* Remove the Bootstrap 3 version stories

* Replace Font Awesome icons to Material icons

* Fix the heading hierarchy

* Cleanup unused translation

* Restore ellipsis to the text of two loading spinners

* Add loading button tests back and add some button loading labels

---------

Co-authored-by: Tim Down <158919+timdown@users.noreply.github.com>
GitOrigin-RevId: 283a9167c8c78bf0fe5062840ded6917dcd6263b
2025-03-24 10:49:33 +00:00
Domagoj Kriskovic
8fe07b196b Fix import path for UpgradeTrackChangesModal in review mode switcher (#24410)
GitOrigin-RevId: 929c85480d253ff5786d99812f4959683f050b52
2025-03-24 10:49:25 +00:00
Domagoj Kriskovic
7f67df2468 Disable track changes for reviewers in free projects (#24368)
* Disable track changes for reviewers in free projects

* cleanup_unused_locales

GitOrigin-RevId: 806ee20c0aed5ef61352041782af620cec72ed70
2025-03-24 10:49:20 +00:00
Domagoj Kriskovic
47fb3a644c Script for generating add-on prices (from recurly) (#24051)
* Script for generating addon prices (from recurly)

* addon code as param

GitOrigin-RevId: b1a45a806c29de56a10532398b56468f9732593f
2025-03-24 10:49:15 +00:00
Liangjun Song
e44f892cb0 Merge pull request #24271 from overleaf/ls-script-to-update-manually-billed-users
Scripts to update terms and conditions for manually billed users

GitOrigin-RevId: 5efe43a42c3ed21779c9de698268817e2cbb5249
2025-03-24 10:49:11 +00:00
Domagoj Kriskovic
6932b3deb7 Add AI feature detection to subscription in OAuth user info (#24240)
* Add AI feature detection to subscription in OAuth user info

* remove subscriptionHasAi

GitOrigin-RevId: 73cdbe452cc5c7f5e379d29723978b33cb827762
2025-03-24 10:49:02 +00:00
Andrew Rumble
800b151024 Ensure metrics exist before collecting them
GitOrigin-RevId: 811301ddebb794341dccc0f27954c66dc5c44aa4
2025-03-24 10:48:58 +00:00
Andrew Rumble
47d8e59938 Add code to shutdown message for debugging
GitOrigin-RevId: 1efbda8c82ca1bc160ec4334388fb14259be563c
2025-03-24 10:48:53 +00:00
Andrew Rumble
56f8993bd7 Remove low information log message
GitOrigin-RevId: 8e961d7dea04debe0f459db3b461673618ab7597
2025-03-24 10:48:48 +00:00
Andrew Rumble
432a92173a Perform verification smoothly over time window
GitOrigin-RevId: b00c18a61a5473f3b7353fd5ab1e55f191722ecd
2025-03-24 10:48:44 +00:00
Andrew Rumble
2779691cd9 Add a script for verifying a set of blobs have been backed up
GitOrigin-RevId: 8f7af647ce0ac86ee9800cdf3d386fb78debed7a
2025-03-24 10:48:39 +00:00
Andrew Rumble
d2d556ddf6 Allow cached persistor to get size of a blob
GitOrigin-RevId: 7e2c14381cf717c21325917ef4f78acb340a50d9
2025-03-24 10:48:35 +00:00
David
0895b5c6ee Merge pull request #24405 from overleaf/dp-review-panel-new-cleanup
Remove review-panel-new namespacing css class

GitOrigin-RevId: 3a3f62674763d1b6b4b1a69e6a72b676cdd549c0
2025-03-24 10:48:30 +00:00
Tim Down
7168572e74 Merge pull request #24336 from overleaf/td-bs5-migrate-cypress-tests
Migrate some Cypress tests to Bootstrap 5

GitOrigin-RevId: a3de8680046c35f8cc1df8baef60981d8eb52580
2025-03-24 10:48:25 +00:00
Tim Down
c2da12939e Merge pull request #24313 from overleaf/td-ac-bs5-frontend-tests
Update front-end tests to use Bootstrap 5

GitOrigin-RevId: abaa09f8c0639d64d6ade97468ab16204e5de97b
2025-03-24 10:48:20 +00:00
Alf Eaton
b8d74c6ae0 Remove 'review-panel-redesign' split test and old code (#24235)
GitOrigin-RevId: 1f3d4a9a51429591a82391a9bee3cfdf226bc9c8
2025-03-24 10:48:15 +00:00
Alf Eaton
e56c4304a1 Avoid re-rendering sync buttons when code/pdf position changes (#24192)
GitOrigin-RevId: cc17fc15df356bde6a737d6e60479cdc2e421d3e
2025-03-24 10:48:02 +00:00
Alf Eaton
9fd4e4ab87 Display changes as additions when only whitespace content is removed (#24296)
GitOrigin-RevId: b6527e8a1c4a73faeed01538212224349ad70614
2025-03-24 10:47:53 +00:00
Alf Eaton
d7cddd14fa Use first root .tex file as fallback main doc when importing from zip (#24302)
GitOrigin-RevId: 51affe14b77aa4f774d5e5f0807f42e07842f807
2025-03-24 10:47:44 +00:00
Alf Eaton
67a6857ca6 Increase the file upload rate limit from 200 to 500 per 15 mins (#24312)
GitOrigin-RevId: e57cc9f8dc12d40ce30eba0ef8067dd3ae72f3ea
2025-03-24 10:47:40 +00:00
M Fahru
57f389646c Merge pull request #24360 from overleaf/mf-initialize-stripe-package-and-keys
[web] Init Stripe packages and keys

GitOrigin-RevId: e707704db6d758a0309f398bfde8900376b5cc67
2025-03-24 10:47:20 +00:00
Jessica Lawshe
f3a19f48d8 Merge pull request #24377 from overleaf/jel-xml-crypto-update
[web] Upgrade dependencies of dependencies for `xml-crypto`

GitOrigin-RevId: 004302f7b5a780e3a43c427182e21fb162df8dd1
2025-03-24 10:47:16 +00:00
Domagoj Kriskovic
19852ed180 Update project-joined event to include role, mode, ownerId, and source (#23677)
* Update project-joined event to include role, mode, ownerId, and source

* fix test

GitOrigin-RevId: 67c428a80f5791b69a57b6719ec795399e2a98ef
2025-03-24 10:47:11 +00:00
Domagoj Kriskovic
5a33a51076 Fix font family for edit mode switcher dropdown (#24291)
GitOrigin-RevId: ccefe032da6dcbecdad8e076797c08ceb5776d6c
2025-03-24 10:47:06 +00:00
Tim Down
48e0bc28f8 Merge pull request #24365 from overleaf/td-bs5-misc-clean-up
Remove BS3 code from a couple of places

GitOrigin-RevId: 110a8f4c96a5418da27be11471d2e93bb7e01ee8
2025-03-24 10:47:02 +00:00
Tim Down
e98ec386cb Merge pull request #24349 from overleaf/td-bs5-add-secondary-email
Migrate add secondary email page to Bootstrap 5

GitOrigin-RevId: f5d1992f2cb2d313f5950ccfd5c187b9bac72d82
2025-03-24 10:46:57 +00:00
David
9680fd115b Merge pull request #24346 from overleaf/dp-breadcrumbs-flag
Remove remaining direct usage of editor-redesign feature flag

GitOrigin-RevId: c17e377f7e6f7828a77e9620df6e31d1030a98cf
2025-03-24 10:46:52 +00:00
Jakob Ackermann
54f5c3115c [web] convert file-list to typescript (#24354)
* [web] convert file-list to typescript

* [web] add type annotation for clsiServerId without providing default

Co-authored-by: David Powell <david.powell@overleaf.com>

---------

Co-authored-by: David Powell <david.powell@overleaf.com>
GitOrigin-RevId: 5ecb79c2540a3e46e296c6bf7f8573fb65febc3f
2025-03-24 10:46:48 +00:00
Jakob Ackermann
1117ea1b3e [clsi] add helper function for detecting extraneous files (#24376)
* [clsi] add helper function for detecting extraneous files

* [clsi] output.pdfxref and output.synctex.gz are extraneous as well

* [clsi] minor code cleanup

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>

---------

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: 0d6c96c99b6be3adaee1558b1f958da885e6307a
2025-03-24 10:46:43 +00:00
Jakob Ackermann
ff78f687d8 [document-updater] add endpoint for project wide last updated timestamp (#24352)
* [document-updater] fix acceptance test for sending two updates

The Object.create() invocation yields an empty object. The following v
assignment works as expected. The effective update is { v: 43 }.
Processing that fails as no operations were included.

* [document-updater] add endpoint project wide last updated timestamp

* [document-updater] apply review feedback

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>

---------

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>
GitOrigin-RevId: 81397537bfd85c2077f19669860b1391c15b34a3
2025-03-24 10:46:38 +00:00
Brian Gough
31b57e2991 Merge pull request #24367 from overleaf/bg-add-new-project-history-flush-script
add new project history flush script

GitOrigin-RevId: 4d6f3be1ada7191334b934e34e1c9eac59a816d0
2025-03-24 10:46:34 +00:00
Jakob Ackermann
8ada51158f [clsi] add support for downloading output files with dot in extension (#24342)
E.g. output.tar.gz

GitOrigin-RevId: d119a41172bb21fb2de429a64b82d05a1efc2375
2025-03-24 10:46:29 +00:00
Jessica Lawshe
6cb5360c88 Merge pull request #24339 from overleaf/jel-xml-crypto
[web] Upgrade `xml-crypto` in web

GitOrigin-RevId: 0fa810d227da9c65031b843958f8b5e796e3adc8
2025-03-24 10:46:24 +00:00
Jakob Ackermann
70601db76f [fetch-utils] export fetchRedirectWithResponse (#24372)
GitOrigin-RevId: 7cd30479fbe70fce68f2d6ef573187fc4f412749
2025-03-24 10:46:19 +00:00
Jakob Ackermann
7b69d61540 [saas-e2e] initial revision of SaaS E2E tests running in the dev-env (#24311)
* [saas-e2e] initial revision of SaaS E2E tests running in the dev-env

* [v1] make rubocop happy

* [v1] make rubocop happy

* [saas-e2e] more retries for webpack startup check

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

* [web] restrict e2e_test_setup.mjs to running in the dev-env

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>

* [saas-e2e] import latest split-tests from production

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: 034343ee56b8d059090d8a5de74eaac24429b527
2025-03-24 10:46:15 +00:00
Jakob Ackermann
13bf214a3c [web] generate clsi buildId ahead of fetching project content (#24337)
* [web] generate clsi buildId ahead of fetching project content

The buildIds timestamp component will be used for cache invalidation.

* [clsi] strict validation for buildId

* [clsi] validate buildId parameter

GitOrigin-RevId: 88e8b2d48e78fa137b6dca7f2e6b93bbcf88a777
2025-03-24 10:46:02 +00:00
Jakob Ackermann
47ea64c30a [web] operate on shadow-copy of compileResponse.outputFiles (#24330)
GitOrigin-RevId: 49336daf11b2a011d03b7469aa4c0ffed72b279a
2025-03-24 10:45:58 +00:00
Jakob Ackermann
e94473a1ce [web] add support for regex based allow-list for skipping captcha (#24266)
* [web] double down on validating email addresses

* [web] normalize emails in captcha middleware

* [web] add support for regex based allow-list for skipping captcha

* [web] skip captcha for trusted users on all actions

GitOrigin-RevId: a994ebf6b74e80f462d2dab1fe5113bbffa676a9
2025-03-24 10:45:53 +00:00
Jakob Ackermann
a7818e9b11 [web] update reference to backfill_project_image_name script (#24326)
GitOrigin-RevId: 40a459de2321aa140638d30c5a1d16390c2cdb28
2025-03-24 10:45:48 +00:00
M Fahru
f94adbf039 Merge pull request #24244 from overleaf/mf-add-new-stickers-contentful-11march25
[web] Add new stickers in contentful

GitOrigin-RevId: ebb5411e38ffab7bb1dc366de7521d03bad2ab27
2025-03-18 09:06:02 +00:00
M Fahru
ec13227fc6 Merge pull request #21520 from overleaf/mf-fedramp-ad
[web][will merge 17 march 2025] Add "FedRamp" advertisement variant to the US Gov banner list with a special rule

GitOrigin-RevId: 86f96c6c108f72b44658dca53d2cecabf0fe9d14
2025-03-18 09:05:58 +00:00
Jakob Ackermann
a530cca2c5 [history-v1] increase timeout for preparing test environment (#24345)
GitOrigin-RevId: a11a16f07da61603218ff14bef32172d4f32c469
2025-03-18 09:05:53 +00:00
Brian Gough
0292bc418d Merge pull request #24335 from overleaf/bg-warn-old-pending-changes
add warning for backups outside rpo threshold

GitOrigin-RevId: a8421529ae64693d860b0325961b010a132426da
2025-03-18 09:05:49 +00:00
Jakob Ackermann
e99cd74cca [fetch-utils] add fetchRedirectWithResponse (#24341)
GitOrigin-RevId: 9b1e9b02399beea87dbae0f87d1d7d897d145178
2025-03-18 09:05:44 +00:00
David
dcabf55882 Merge pull request #24137 from overleaf/mj-ide-permissions-utils
[web] Add switcher for editor redesign

GitOrigin-RevId: 806a1f567027df53f879b564a50aaae9166c8480
2025-03-18 09:05:40 +00:00
Eric Mc Sween
35dc7faab6 Merge pull request #24224 from overleaf/em-resync-on-flush-failure
Immediately attempt a resync when a history flush fails

GitOrigin-RevId: 098a0a7edf55c0ed47c48e0a3c080e3562cdceaa
2025-03-18 09:05:36 +00:00
Tim Down
c5b584e3d8 Merge pull request #24165 from overleaf/lg-td-firefox-78-unsupported
Redirect Firefox 78 and lower to unsupported browser page

GitOrigin-RevId: 1da7379c622b3ae61c3309295c769fe293c0e716
2025-03-18 09:05:21 +00:00
Tim Down
09b68de041 Merge pull request #24083 from overleaf/td-bs5-misc-bs3-removal
Remove Bootstrap 3 components in a few places

GitOrigin-RevId: 0380506543b40f3d8d83f2bf6199af28f61ad5af
2025-03-18 09:05:17 +00:00
Tim Down
7c7cc0fce0 Merge pull request #24021 from overleaf/td-social-logos
Update social media logos in footer

GitOrigin-RevId: a9498d7501c6d4c4ce1b76884f0f278dd92d4cf7
2025-03-18 09:05:12 +00:00
Brian Gough
3f7c88108c Merge pull request #24275 from overleaf/bg-fix-pending-change-timestamp
fix pending change timestamp

GitOrigin-RevId: 9297a4b57ea718e6a2e1ca62388919c62911af6c
2025-03-18 09:05:08 +00:00
Andrew Rumble
f134746c9c Extend script to allow multiple blobs to be processed from csv
GitOrigin-RevId: ad47eb754436ddc7f56b27ceda627268c3a030a1
2025-03-18 09:05:03 +00:00
Andrew Rumble
b5d6484991 Add a script to backup a single blob from a project
GitOrigin-RevId: 464e6d69093b87891497e07d1627cd20e2285380
2025-03-18 09:04:59 +00:00
Andrew Rumble
78481e010e Add verification looper and handle shutdown signals
Shutdown signals become more relevant now that we are looping as we want
to gracefully stop processing records rather than continue looping.

GitOrigin-RevId: dbb499388c86d552d77954988f8fc27d140da3f1
2025-03-18 09:04:54 +00:00
Andrew Rumble
9d72eeeeac Add new strategy to verify_sampled_projects
GitOrigin-RevId: d967da41250bb5945d5b8668b212d4a61b4f9d69
2025-03-18 09:04:50 +00:00
Andrew Rumble
f85fdd3a97 Refactor project sampler and add new sampler type
GitOrigin-RevId: 984aa35cef1165e1c8342073b9211a387bd6089e
2025-03-18 09:04:46 +00:00
Jakob Ackermann
f6bd485863 [misc] align initializing of mongodb replica set (#24287)
* [misc] align initializing of mongodb replica set

* [misc] fix volumes for mono container in dev-env

* Remove duplicate volumes key

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: 0119c42ee8b13a0fca9373e40a27fc5b2a52d73b
2025-03-17 09:04:58 +00:00
Jakob Ackermann
5cdaa424ee [clsi] increase CACHE_AGE for output files to 90 minutes (#24322)
GitOrigin-RevId: d6208281be8d9a97278f7c60e203de68eb3f7501
2025-03-17 09:04:53 +00:00
Jakob Ackermann
0c3a62297a [clsi] collect disk usage more frequently (#24314)
GitOrigin-RevId: 0c5b3ebeadec6d2ac70b7adc77935aa0cea92ffc
2025-03-17 09:04:49 +00:00
CloudBuild
a097577e29 auto update translation
GitOrigin-RevId: 3fa7f5bc4f52ddec1c55f0409bc930be1e522971
2025-03-17 09:04:44 +00:00
Jakob Ackermann
f7e716c826 [clsi] add metric for disk usage (#24303)
GitOrigin-RevId: e21b867a2fdaf54e9ec5b29b0f80b29349eb901c
2025-03-14 09:05:23 +00:00
Thomas Mees
84996ea88c Implement checks for user eligibility when switching plans (#24276)
* Convert updateSubscription controller to async/await

* Move updateSubscription to subscription module

* Validate if user is eligible to change plan

GitOrigin-RevId: ce538429cd5a3b93acabdc046f1a8b164ac02301
2025-03-14 09:05:19 +00:00
Mathias Jakobsen
e6371ec197 Merge pull request #24260 from overleaf/mj-ide-rail-selected-states
[web] Editor redesign: Update selected state for rail tabs

GitOrigin-RevId: 4ce868c62a53355f8271db32884db0b2ed985d60
2025-03-14 09:05:00 +00:00
Mathias Jakobsen
971c63a636 Merge pull request #24258 from overleaf/mj-ide-dark-mode-readonly-file
[web] Specify text color of readonly files

GitOrigin-RevId: c7395f4b693c9b413d60e0aba20cc78ea72efd86
2025-03-14 09:04:54 +00:00
Mathias Jakobsen
5a67353dc3 [web] Editor redesign: Add actions to project name dropdown (#24220)
GitOrigin-RevId: 04f4abdc529a0494c70b0e3d14847b0cf452b80d
2025-03-14 09:04:40 +00:00
Jakob Ackermann
eaefecb91d [server-pro] TeXLive 2025 has been released (#24285)
* [server-pro] TeXLive 2025 has been released

* [server-ce] bump copyright year in README

GitOrigin-RevId: ece909181b96f25250559cf387c19cd87bb48097
2025-03-13 09:06:48 +00:00
Jakob Ackermann
8d569815e6 [real-time] only log userId from session (#24281)
While we are at it, log projectId and transport.

GitOrigin-RevId: ac10220247947e0050f1374c81091c50c8cc0e85
2025-03-13 09:06:44 +00:00
Brian Gough
2d48c86e61 Merge pull request #24269 from overleaf/bg-backup-replication-smoothing
extend backup scheduler to delay jobs for load levelling

GitOrigin-RevId: be62c458d3be464bf80ca590fcc107c2760aa061
2025-03-13 09:06:28 +00:00
Brian Gough
a178c0f400 Merge pull request #24268 from overleaf/bg-backup-replication-lag-metrics
add backup replication metrics

GitOrigin-RevId: a734435e3c6ce56350b2286bd218a5e2324d93a9
2025-03-13 09:06:24 +00:00
Brian Gough
cf105cf01d Merge pull request #24267 from overleaf/bg-fix-init-option
update backup script to use index when finding uninitialised projects

GitOrigin-RevId: 04ce0654b3d42de1c1a9bb542482c2dd53540628
2025-03-13 09:06:20 +00:00
Brian Gough
3b93efdf5c Merge pull request #24256 from overleaf/bg-backup-fix-sample
fix backup sample script

GitOrigin-RevId: 4bf4c15324d9b3b7ebd809f7b212e52d75ab216d
2025-03-13 09:06:16 +00:00
Alf Eaton
28ff69b51b Revert "Check for changed files since last merge in GitHub Sync modal (#24180)" (#24273)
This reverts commit 4cd55e1501648f0a18b77be5af19deb0773d6881.

GitOrigin-RevId: 132eead97e924037c2fd17fd636a769ef95f4cc1
2025-03-13 09:06:06 +00:00
Alf Eaton
4ddd3ee772 Check for changed files since last merge in GitHub Sync modal (#24180)
GitOrigin-RevId: 4cd55e1501648f0a18b77be5af19deb0773d6881
2025-03-13 09:06:02 +00:00
Andrew Rumble
92499f6260 Revert "Revert "Add a metric so that we can know maint is prevented""
This reverts commit b3c4e7181b5f6f52178e165f6220c485e8e97caf.

GitOrigin-RevId: d5525bbe5cb1546cdff8e201de04762a8d86e332
2025-03-13 09:05:59 +00:00
Jakob Ackermann
3d9bc77fce [misc] compress in ssl_proxy instead of webpack-dev-server (#24263)
GitOrigin-RevId: 200cf9c49a68412591a359f46f0d1d900e303520
2025-03-13 09:05:52 +00:00
Christopher Hoskin
c3ade4dce1 Merge pull request #24070 from overleaf/csh-fix-check_institution_users
Allow higher timeouts

GitOrigin-RevId: 8b762cab7cf1420cec732ea592a8075e96b9a5f8
2025-03-13 09:05:45 +00:00
Miguel Serrano
9470c3a44b Merge pull request #23790 from overleaf/msm-base-image-update
[CE] Trigger CE `Dockerfile-base` build

GitOrigin-RevId: b96b8b2d612b5b0a4079b9fafd6cf3c071c6ab8d
2025-03-13 09:05:35 +00:00
Miguel Serrano
89b4eaf391 Merge pull request #24130 from overleaf/msm-proxy-agent-learn
[web] Add `https-proxy-agent` for learn wiki

GitOrigin-RevId: 215f0f3ad5254b0620692c1cc4c90b50bdf1d4ef
2025-03-13 09:05:31 +00:00
Miguel Serrano
79dcab4ef5 Merge pull request #24231 from overleaf/msm-fix-separator-footer
[web] Fix separator in thin-footer

GitOrigin-RevId: 8b5700ea7998e9fe451dded10321757ee5fa47a6
2025-03-13 09:05:27 +00:00
David
542a52c510 Merge pull request #24210 from overleaf/mj-nested-menu-bar
[web] Editor redesign: Make menu bar nestable

GitOrigin-RevId: 5c08126499ff96494d6af9adcbd75126ddd596af
2025-03-13 09:05:23 +00:00
David
10b0d6333f Merge pull request #24177 from overleaf/mj-ide-symbol-palette
[web] Editor redesign: Add symbol palette

GitOrigin-RevId: c9c1e15adef86023b18a6d6efea854777fa4fb11
2025-03-13 09:05:19 +00:00
David
1fcf046c81 Merge pull request #24133 from overleaf/mj-ide-selected-colors
[web] Editor redesign: Update selected states

GitOrigin-RevId: a43f4504c14c536850ce97c6259bf5d4d67b5418
2025-03-13 09:05:15 +00:00
David
cc72d8b11b Merge pull request #24171 from overleaf/mj-ide-dark-mode-premium
[web] Editor redesign: Fix color of premium badge in dark mode

GitOrigin-RevId: 6a49ec99a2e339635c1e2f7a732a4a1176bdd8f4
2025-03-13 09:05:11 +00:00
David
c4493ebc90 Merge pull request #24197 from overleaf/mj-ide-dark-color-scheme
[web] Editor redesign: Set color-scheme to dark in dark mode

GitOrigin-RevId: 6d62288e4ec9737629ed2e7a78865c6017ccea59
2025-03-13 09:05:07 +00:00
David
3d9b1bb177 Merge pull request #23914 from overleaf/dp-history
Add existing history view into new editor

GitOrigin-RevId: 0d35ba1f38dafd8a71087f231f2cddc1b0424401
2025-03-13 09:05:03 +00:00
David
ea33c7d896 Merge pull request #24233 from overleaf/dp-dark-breadcrumbs
Add dark mode for breadcrumbs

GitOrigin-RevId: f1425a1da514f74854fd7afe176e27e2b62dae9c
2025-03-13 09:04:59 +00:00
Andrew Rumble
768180c456 Handle errors during startup
GitOrigin-RevId: ba8e5964e78439b0ea3650191f5ea9c9e22f2b50
2025-03-13 09:04:55 +00:00
Andrew Rumble
dad6f97cce Load global blobs when starting backup-verifier
GitOrigin-RevId: 31aa2a3639f8d9a69b5b98f27aff17b98bd14694
2025-03-13 09:04:51 +00:00
Andrew Rumble
273ae4aecd Split healthCheck out into separate module
GitOrigin-RevId: 847d00b696fe6d82f4bd5fea8f9130437c68e7b2
2025-03-13 09:04:47 +00:00
Andrew Rumble
b5f8bfa28e Switch health check to use projects instead of blobs
Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: db1a1c8ce5968e558b0754e5e0da50af89fd80db
2025-03-13 09:04:43 +00:00
Antoine Clausse
959562661f [web] Make Bootstrap 5 the default for new pages (#24079)
* Update layout-base.pug to treat missing `bootstrap5PageStatus` as enabled

* Set `bootstrap5PageStatus = 'disabled'` everywhere it's undefined

I found the files with `find . -name "*.pug" -type f -exec grep -l "block content" {} \; | xargs grep -L "bootstrap5PageStatus\|layout-website-redesign-boo
                        tstrap-5\|layout-website-redesign-cms-bootstrap-5" | sort`

GitOrigin-RevId: 8ecf2eedea032d96e4fba078fb0316a7a311b08a
2025-03-12 09:06:50 +00:00
Jimmy Domagala-Tang
80abd0ac2c Merge pull request #23972 from overleaf/jdt-grant-assist-via-wf-set-trait
enable granting of premium error assist based on WF entitlement to bu…

GitOrigin-RevId: 9d21cf8755c881bdc698c0cf9891076ecefd34eb
2025-03-12 09:06:42 +00:00
Andrew Rumble
19eefebe95 Revert "Switch health check to use projects instead of blobs"
This reverts commit c318b70397ed5e2fcbb07fa019412b56844260ef.

GitOrigin-RevId: 087ae9d21be83bf3dae47c4e6d27eb4e74f387df
2025-03-12 09:06:34 +00:00
Andrew Rumble
087a9daf34 Revert "Split healthCheck out into separate module"
This reverts commit 96061812977d5c854e494cd44163b16a96722b17.

GitOrigin-RevId: f30a185b65a4f1346ed13fa0c6e9ea0852d44335
2025-03-12 09:06:30 +00:00
Andrew Rumble
a7be1f3430 Split healthCheck out into separate module
GitOrigin-RevId: 96061812977d5c854e494cd44163b16a96722b17
2025-03-12 09:06:22 +00:00
Andrew Rumble
c373db4f86 Switch health check to use projects instead of blobs
Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: c318b70397ed5e2fcbb07fa019412b56844260ef
2025-03-12 09:06:18 +00:00
Jessica Lawshe
3bc21faeaf Merge pull request #23088 from overleaf/jel-managed-users-enroll
[web] Show message instead of enrollment form when already managed

GitOrigin-RevId: ffa9dfd8af17ee3128df15d4027a66b96f2c8b0d
2025-03-12 09:06:03 +00:00
Jessica Lawshe
f9515f10cd Merge pull request #24191 from overleaf/jel-latexqc-bootstrap
[latexqc] Remove `react-bootstrap` and upgrade to `bootstrap` 5

GitOrigin-RevId: 39d9a12121d34e3844a8e80a50bc6d3bc73c1808
2025-03-12 09:05:59 +00:00
Andrew Rumble
0b387c5116 Simplify error identification
GitOrigin-RevId: 6dae1ed99269deb26e15ec921ba173f85f5e6fc9
2025-03-12 09:05:48 +00:00
Andrew Rumble
149b590413 Fix script name in usage prompt
GitOrigin-RevId: ab4ed7479c96d3d1db82b23ef3c883706c61995e
2025-03-12 09:05:44 +00:00
Andrew Rumble
282f5f92ff Ensure global blobs are loaded before verifying
GitOrigin-RevId: 3f78c44e6b4d98fd6c469b0f4fbacc033b0a0868
2025-03-12 09:05:39 +00:00
Andrew Rumble
afedce1b0e Add script for verifying sampled sets of projects
GitOrigin-RevId: 23a599ea269a528201c67cda66bd2d00c0d376e4
2025-03-12 09:05:35 +00:00
Andrew Rumble
061d67ee4b Emit more specific errors from backupVerifier
GitOrigin-RevId: 99475608f096be3e35fbaaf1825b99d145ea86f3
2025-03-12 09:05:31 +00:00
Andrew Rumble
36056e75d7 Improve chunk loading in backupVerifier
Brings the process closer to history_store.

We can't use the backup history_store because the keys are generated
differently for chunks than the standard history_store way of doing it.

GitOrigin-RevId: 07adfc0531f6ec0f38bb70ea0fe8ae0d41f508cc
2025-03-12 09:05:26 +00:00
Andrew Rumble
d04bfbcdea Add promiseMapSettledWithLimit utility
GitOrigin-RevId: e34102de17f28e43deb383d630088c6e753e2ec1
2025-03-12 09:05:22 +00:00
Alf Eaton
ecc2f1f544 Merge pull request #24196 from overleaf/ae-fetch-data-access
Avoid fetching unavailable project data

GitOrigin-RevId: 58fd58f098af5e3eb000e31f22b403d3e28ef691
2025-03-12 09:05:18 +00:00
Alf Eaton
a11266471c Merge pull request #22661 from overleaf/ae-remove-client-opentelemetry
Remove OpenTelemetry from the web frontend

GitOrigin-RevId: fc81052724afd7f62c44e17de3adffbb7c1a62c2
2025-03-12 09:05:13 +00:00
Andrew Rumble
302362c70d Return a NotFoundError when filestore disabled
GitOrigin-RevId: b470d75fc621e2e52180cf923d0ee818f4ec4cb2
2025-03-12 09:05:09 +00:00
Brian Gough
efd53e567c Merge pull request #24237 from overleaf/bg-fix-backup-worker
fixes for backup worker

GitOrigin-RevId: 79c99e5b2d99d1b0298f69d968e0e2cb89281dd6
2025-03-12 09:05:04 +00:00
Andrew Rumble
0002e008bb Revert "Add a metric so that we can know maint is prevented"
This reverts commit a4df6ce7f22df01e1116ae9dbf5c7c7afc73c388.

GitOrigin-RevId: b3c4e7181b5f6f52178e165f6220c485e8e97caf
2025-03-12 09:04:57 +00:00
ilkin-overleaf
96af83a4ed Merge pull request #24138 from overleaf/ii-flexible-licensing-colombian-pesos
[web] Recurly subtotal limit on flexible licensing

GitOrigin-RevId: 302fb15dcc360e3b47674e8e776ffa115af6cbe6
2025-03-12 09:04:52 +00:00
Andrew Rumble
00aa26cd1a Add a metric so that we can know maint is prevented
GitOrigin-RevId: a4df6ce7f22df01e1116ae9dbf5c7c7afc73c388
2025-03-12 09:04:48 +00:00
Brian Gough
3cad54b215 Merge pull request #24186 from overleaf/bg-backup-index-on-missing-lastBackedUpVersion
add a mongo index to find projects which are not backed up

GitOrigin-RevId: d776ebac2bef348117dc87556156e4682ac1b3e3
2025-03-11 09:06:47 +00:00
Brian Gough
a04d3198a8 Merge pull request #24181 from overleaf/bg-Project-Audit-Log-Transfer-ownership-is-missing-the-IP-address
add missing ip adress to audit log for transfer ownership

GitOrigin-RevId: c14494efce25efa92a6cd81bcf197d8f897571bb
2025-03-11 09:06:43 +00:00
CloudBuild
aaa15a2733 auto update translation
GitOrigin-RevId: 9617a83b396ecec5c4b8a50a858db49257326027
2025-03-11 09:06:26 +00:00
M Fahru
cae698b705 Merge pull request #23994 from overleaf/mf-20-millions-update-user-count
[web] Update user count to 20 millions

GitOrigin-RevId: 768b08b70cdbcff0435897f7cf518de3dff80f55
2025-03-11 09:06:12 +00:00
Brian Gough
c233243948 Merge pull request #24200 from overleaf/bg-backup-queue-pending-jobs
fix backup worker and backup scheduler to handle pending projects

GitOrigin-RevId: a97e011615666b3ae2b8fafe26a96d41b3609edd
2025-03-11 09:06:05 +00:00
Andrew Rumble
f045361b49 Merge pull request #24205 from overleaf/ar-log-additional-information-when-getting-a-file-from-filestore
[web] Gather information about files with no hash

GitOrigin-RevId: 7f509c13f14902a40ae39bf1889103274de23040
2025-03-11 09:06:01 +00:00
Andrew Rumble
441c7a89a7 Merge pull request #24204 from overleaf/ar-jpa-add-chunk-verification
[history-v1] add chunk verification

GitOrigin-RevId: 7208ad20872386813bb1c6946283afddb5e8b1cf
2025-03-11 09:05:57 +00:00
Davinder Singh
7ec4cbd841 Merge pull request #24164 from overleaf/ds-group-discount-10-percent-february
Group plans - Reintroduce 10% discount for group plans (Part 1, 2 & 3 Combined)

GitOrigin-RevId: f3a59a65bbd300cc06f70e179e794c32ed7970ce
2025-03-11 09:05:50 +00:00
Eric Mc Sween
1ea577ef12 Merge pull request #24154 from overleaf/em-bs5-subscription-preview
Migrate the subscription change preview page to BS5

GitOrigin-RevId: beb398ef3a81be1b8d35536c524af6c110ea1b3d
2025-03-11 09:05:46 +00:00
Alf Eaton
eedf5367fc Store/update entity sha after importing from GitHub (#23935)
* Refactor WebApiManager
* Store/update entity sha after importing

GitOrigin-RevId: 604bf3b8010140355c21afca01a54237a301eb92
2025-03-11 09:05:42 +00:00
Alf Eaton
fe4f41501f Parse labels from environment options (#24189)
GitOrigin-RevId: e51eed7521f6e32e614f8b38092a0b0219f7f186
2025-03-11 09:05:31 +00:00
Mathias Jakobsen
f11ad91249 Merge pull request #24201 from overleaf/mj-fix-ce-contact
[web] Import contact us modal via macro

GitOrigin-RevId: 6d8a14c33fb158a38413d32036b17dd4b3debdd9
2025-03-11 09:05:24 +00:00
ilkin-overleaf
1c4a761478 Merge pull request #24057 from overleaf/ii-group-management-improvements
[web] Group management improvements

GitOrigin-RevId: 01826bf37fa8d5e06595f660ccb42a8df00b63ae
2025-03-11 09:05:20 +00:00
ilkin-overleaf
c3c14ccfbc Merge pull request #24028 from overleaf/ii-bs5-group-settings
[web] BS5 group settings

GitOrigin-RevId: d4f0a16c653548bee66a65a5a5db056c8cc09666
2025-03-11 09:05:16 +00:00
David
9824151e62 Merge pull request #24033 from overleaf/mj-rail-dropdown
[web] Editor redesign: Add help dropdown to rail

GitOrigin-RevId: 052ba1852a8e2702fe1671c4613d986b9b0c91c2
2025-03-11 09:05:12 +00:00
David
d7ad742ba3 Merge pull request #23747 from overleaf/dp-breadcrumbs
Add file breadcrumbs to new editor

GitOrigin-RevId: 54bde446ad632976503a2c4aff915c862bad710e
2025-03-11 09:05:08 +00:00
Jakob Ackermann
087c41190e [web] switch query for hard-deleting users and add index (#22920)
* [web] switch query for hard-deleting users and add index

Co-authored-by: Dr. Sasha Göbbels <sasha.goebbels@overleaf.com>

* [web] fix unit tests

---------

Co-authored-by: Dr. Sasha Göbbels <sasha.goebbels@overleaf.com>
GitOrigin-RevId: a7fd2a590351d2e0d60c0032ca78d457ef815e41
2025-03-11 09:05:04 +00:00
Rebeka Dekany
69bc8a135b Bootstrap 3 cleanup from the IDE page - #2 (#24175)
* Remove skipLoadingStyleSheet

* Remove unused bootstrap-5 assignment from the Account settings page since it's archived

* Remove bsVersionIcon

* Remove bsVersion, bootstrapVersion and isBootstrap5 from elements on the IDE page

* Remove BS3Dropdown from the context menu

* Cleanup Bootstrap 3 related comment and type

GitOrigin-RevId: a67244eb78943ee84cc5f89bae164c0361e8fc13
2025-03-11 09:05:00 +00:00
CloudBuild
6a344c7a52 auto update translation
GitOrigin-RevId: 037a91f43519537e0dc572771f8cbe92659d61df
2025-03-10 09:05:31 +00:00
CloudBuild
b5031fdee5 auto update translation
GitOrigin-RevId: ba3022ee74dee8356611c970a9cd880930023d34
2025-03-10 09:05:27 +00:00
Brian Gough
15e5501ddd Merge pull request #23119 from overleaf/bg-issue23112
Add script to SP/CE to export user projects

GitOrigin-RevId: d8a5af638ccc2376ce2af25ef00904be5aa682e3
2025-03-10 09:05:23 +00:00
Rebeka Dekany
5974eed4aa Upgrade Storybook to 8.4.7 (#24144)
GitOrigin-RevId: df469e5d4123f0a4704a8670896ad29797f0f92b
2025-03-10 09:05:19 +00:00
Brian Gough
8a4c84e7dd Merge pull request #24131 from overleaf/bg-disable-call-to-filestore-in-project-deleter
disable call to filestore in project deleter

GitOrigin-RevId: 07d9d3b33220e33abfa18a5f54e56e2b8d544e8d
2025-03-10 09:05:15 +00:00
726 changed files with 15163 additions and 15356 deletions

View file

@ -73,4 +73,4 @@ Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contribut
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
Copyright (c) Overleaf, 2014-2024.
Copyright (c) Overleaf, 2014-2025.

View file

@ -11,12 +11,6 @@ bin/build
> [!NOTE]
> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`.
Next, initialize the database:
```shell
bin/init
```
Then start the services:
```shell

View file

@ -1,6 +0,0 @@
#!/usr/bin/env bash
docker compose up --detach mongo
curl --max-time 10 --retry 5 --retry-delay 5 --retry-all-errors --silent --output /dev/null localhost:27017
docker compose exec mongo mongosh --eval "rs.initiate({ _id: 'overleaf', members: [{ _id: 0, host: 'mongo:27017' }] })"
docker compose down mongo

View file

@ -94,6 +94,14 @@ services:
- "127.0.0.1:27017:27017" # for debugging
volumes:
- mongo-data:/data/db
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
notifications:
build:

View file

@ -103,7 +103,7 @@ services:
command: '--replSet overleaf'
volumes:
- ~/mongo_data:/data/db
- ./server-ce/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:

View file

@ -95,6 +95,19 @@ async function fetchNothing(url, opts = {}) {
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirect(url, opts = {}) {
const { location } = await fetchRedirectWithResponse(url, opts)
return location
}
/**
* Make a request and extract the redirect from the response.
*
* @param {string | URL} url - request URL
* @param {object} opts - fetch options
* @return {Promise<{location: string, response: Response}>}
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirectWithResponse(url, opts = {}) {
const { fetchOpts } = parseOpts(opts)
fetchOpts.redirect = 'manual'
const response = await performRequest(url, fetchOpts)
@ -112,7 +125,7 @@ async function fetchRedirect(url, opts = {}) {
)
}
await discardResponseBody(response)
return location
return { location, response }
}
/**
@ -297,6 +310,7 @@ module.exports = {
fetchStreamWithResponse,
fetchNothing,
fetchRedirect,
fetchRedirectWithResponse,
fetchString,
fetchStringWithResponse,
RequestFailedError,

View file

@ -16,6 +16,7 @@ let VERBOSE_LOGGING
let BATCH_RANGE_START
let BATCH_RANGE_END
let BATCH_MAX_TIME_SPAN_IN_MS
let BATCHED_UPDATE_RUNNING = false
/**
* @typedef {import("mongodb").Collection} Collection
@ -211,57 +212,66 @@ async function batchedUpdate(
findOptions,
batchedUpdateOptions
) {
ID_EDGE_PAST = await getIdEdgePast(collection)
if (!ID_EDGE_PAST) {
console.warn(
`The collection ${collection.collectionName} appears to be empty.`
)
return 0
// only a single batchedUpdate can run at a time due to global variables
if (BATCHED_UPDATE_RUNNING) {
throw new Error('batchedUpdate is already running')
}
refreshGlobalOptionsForBatchedUpdate(batchedUpdateOptions)
findOptions = findOptions || {}
findOptions.readPreference = READ_PREFERENCE_SECONDARY
projection = projection || { _id: 1 }
let nextBatch
let updated = 0
let start = BATCH_RANGE_START
while (start !== BATCH_RANGE_END) {
let end = getNextEnd(start)
nextBatch = await getNextBatch(
collection,
query,
start,
end,
projection,
findOptions
)
if (nextBatch.length > 0) {
end = nextBatch[nextBatch.length - 1]._id
updated += nextBatch.length
if (VERBOSE_LOGGING) {
console.log(
`Running update on batch with ids ${JSON.stringify(
nextBatch.map(entry => entry._id)
)}`
)
} else {
console.error(`Running update on batch ending ${renderObjectId(end)}`)
}
if (typeof update === 'function') {
await update(nextBatch)
} else {
await performUpdate(collection, nextBatch, update)
}
try {
BATCHED_UPDATE_RUNNING = true
ID_EDGE_PAST = await getIdEdgePast(collection)
if (!ID_EDGE_PAST) {
console.warn(
`The collection ${collection.collectionName} appears to be empty.`
)
return 0
}
console.error(`Completed batch ending ${renderObjectId(end)}`)
start = end
refreshGlobalOptionsForBatchedUpdate(batchedUpdateOptions)
findOptions = findOptions || {}
findOptions.readPreference = READ_PREFERENCE_SECONDARY
projection = projection || { _id: 1 }
let nextBatch
let updated = 0
let start = BATCH_RANGE_START
while (start !== BATCH_RANGE_END) {
let end = getNextEnd(start)
nextBatch = await getNextBatch(
collection,
query,
start,
end,
projection,
findOptions
)
if (nextBatch.length > 0) {
end = nextBatch[nextBatch.length - 1]._id
updated += nextBatch.length
if (VERBOSE_LOGGING) {
console.log(
`Running update on batch with ids ${JSON.stringify(
nextBatch.map(entry => entry._id)
)}`
)
} else {
console.error(`Running update on batch ending ${renderObjectId(end)}`)
}
if (typeof update === 'function') {
await update(nextBatch)
} else {
await performUpdate(collection, nextBatch, update)
}
}
console.error(`Completed batch ending ${renderObjectId(end)}`)
start = end
}
return updated
} finally {
BATCHED_UPDATE_RUNNING = false
}
return updated
}
/**

View file

@ -414,6 +414,16 @@ class CachedPerProjectEncryptedS3Persistor {
return await this.sendStream(bucketName, path, fs.createReadStream(fsPath))
}
/**
*
* @param {string} bucketName
* @param {string} path
* @return {Promise<number>}
*/
async getObjectSize(bucketName, path) {
return await this.#parent.getObjectSize(bucketName, path)
}
/**
* @param {string} bucketName
* @param {string} path

View file

@ -13,6 +13,7 @@ module.exports = {
expressify,
expressifyErrorHandler,
promiseMapWithLimit,
promiseMapSettledWithLimit,
}
/**
@ -264,3 +265,19 @@ async function promiseMapWithLimit(concurrency, array, fn) {
const limit = pLimit(concurrency)
return await Promise.all(array.map(x => limit(() => fn(x))))
}
/**
* Map values in `array` with the async function `fn`
*
* Limit the number of unresolved promises to `concurrency`.
*
* @template T, U
* @param {number} concurrency
* @param {Array<T>} array
* @param {(T) => Promise<U>} fn
* @return {Promise<Array<PromiseSettledResult<U>>>}
*/
function promiseMapSettledWithLimit(concurrency, array, fn) {
const limit = pLimit(concurrency)
return Promise.allSettled(array.map(x => limit(() => fn(x))))
}

2468
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -73,6 +73,7 @@
"services/third-party-datastore",
"services/third-party-references",
"services/tpdsworker",
"services/web"
"services/web",
"tools/saas-e2e"
]
}

View file

@ -10,7 +10,7 @@ ENV TEXMFVAR=/var/lib/overleaf/tmp/texmf-var
# Update to ensure dependencies are updated
# ------------------------------------------
ENV REBUILT_AFTER="2024-15-10"
ENV REBUILT_AFTER="2025-03-27"
# Install dependencies
# --------------------

1
server-ce/bin/shared Symbolic link
View file

@ -0,0 +1 @@
../../bin/shared/

View file

@ -0,0 +1 @@
FROM sharelatex/sharelatex:4.2.8

View file

@ -0,0 +1 @@
FROM sharelatex/sharelatex:5.3.2

View file

@ -1 +0,0 @@
rs.initiate({ _id: "overleaf", members: [ { _id: 0, host: "mongo:27017" } ] })

View file

@ -30,7 +30,7 @@ server {
application/pdf pdf;
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
alias /var/lib/overleaf/data/output/$1-$2/generated-files/$3/output.$4;
}
# handle .blg files for specific users
@ -38,7 +38,7 @@ server {
alias /var/lib/overleaf/data/output/$1-$2/generated-files/$3/$4.blg;
}
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
alias /var/lib/overleaf/data/output/$1/generated-files/$2/output.$3;
}
# handle .blg files for anonymous users

View file

@ -47,12 +47,12 @@ server {
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}

View file

@ -1 +0,0 @@
.

View file

@ -95,7 +95,9 @@ describe('Project creation and compilation', function () {
cy.findByText('Share').click()
cy.findByRole('dialog').within(() => {
cy.get('input').type('collaborator@example.com,')
cy.findByTestId('collaborator-email-input').type(
'collaborator@example.com,'
)
cy.findByText('Invite').click({ force: true })
cy.findByText('Invite not yet accepted.')
})

View file

@ -38,7 +38,7 @@ services:
image: mongo:6.0
command: '--replSet overleaf'
volumes:
- ../mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:

View file

@ -149,10 +149,10 @@ describe('editor', () => {
openFile(fileName, 'static')
cy.log('reject changes')
cy.findByText('Review').click()
cy.contains('.toolbar-item', 'Review').click()
cy.get('.cm-content').should('not.contain.text', oldContent)
cy.findByText('Reject').click({ force: true })
cy.findByText('Review').click()
cy.findByText('Reject change').click({ force: true })
cy.contains('.toolbar-item', 'Review').click()
cy.log('recompile to force flush')
recompile()
@ -205,10 +205,10 @@ describe('editor', () => {
openFile(fileName, 'static')
cy.log('reject changes')
cy.findByText('Review').click()
cy.contains('.toolbar-item', 'Review').click()
cy.get('.cm-content').should('not.contain.text', oldContent)
cy.findAllByText('Reject').first().click({ force: true })
cy.findByText('Review').click()
cy.findAllByText('Reject change').first().click({ force: true })
cy.contains('.toolbar-item', 'Review').click()
cy.log('recompile to force flush')
recompile()

View file

@ -59,7 +59,7 @@ describe('LDAP', () => {
it('login', () => {
cy.visit('/')
cy.findByText('Login LDAP')
cy.findByText('Log in LDAP')
cy.get('input[name="login"]').type('fry')
cy.get('input[name="password"]').type('fry')

View file

@ -136,7 +136,7 @@ describe('git-bridge', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-rw@example.com',
'Can edit'
'Editor'
)
maybeClearAllTokens()
openProjectByName(projectName)
@ -147,7 +147,7 @@ describe('git-bridge', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-ro@example.com',
'Can view'
'Viewer'
)
maybeClearAllTokens()
openProjectByName(projectName)

View file

@ -24,7 +24,7 @@ export function prepareWaitForNextCompileSlot() {
queueReset()
triggerCompile()
cy.log('Wait for compile to finish')
cy.findByText('Recompile')
cy.findByText('Recompile').should('be.visible')
})
}
function recompile() {

View file

@ -100,7 +100,7 @@ export function openProjectViaInviteNotification(projectName: string) {
function shareProjectByEmail(
projectName: string,
email: string,
level: 'Can view' | 'Can edit'
level: 'Viewer' | 'Editor'
) {
openProjectByName(projectName)
cy.findByText('Share').click()
@ -108,7 +108,13 @@ function shareProjectByEmail(
cy.findByLabelText('Add people', { selector: 'input' }).type(`${email},`)
cy.findByLabelText('Add people', { selector: 'input' })
.parents('form')
.within(() => cy.findByText('Can edit').parent().select(level))
.within(() => {
cy.findByTestId('add-collaborator-select')
.click()
.then(() => {
cy.findByText(level).click()
})
})
cy.findByText('Invite').click({ force: true })
cy.findByText('Invite not yet accepted.')
})
@ -117,7 +123,7 @@ function shareProjectByEmail(
export function shareProjectByEmailAndAcceptInviteViaDash(
projectName: string,
email: string,
level: 'Can view' | 'Can edit'
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
@ -128,7 +134,7 @@ export function shareProjectByEmailAndAcceptInviteViaDash(
export function shareProjectByEmailAndAcceptInviteViaEmail(
projectName: string,
email: string,
level: 'Can view' | 'Can edit'
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
@ -212,11 +218,11 @@ export function createNewFile() {
export function toggleTrackChanges(state: boolean) {
cy.findByText('Review').click()
cy.get('.rp-tc-state-collapse').then(el => {
// TODO: simplify this in the frontend?
if (el.hasClass('rp-tc-state-collapse-on')) {
// make track-changes switches visible
cy.get('.rp-tc-state-collapse').click()
cy.get('.track-changes-menu-button').then(el => {
// when the menu is expanded renders the `expand_more` icon,
// and the `chevron_right` icon when it's collapsed
if (!el.text().includes('expand_more')) {
el.click()
}
})
@ -241,5 +247,5 @@ export function toggleTrackChanges(state: boolean) {
cy.wait(alias)
})
})
cy.findByText('Review').click()
cy.contains('.toolbar-item', 'Review').click()
}

View file

@ -154,7 +154,7 @@ describe('Project Sharing', function () {
beforeEach(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaEmail(projectName, email, 'Can view')
shareProjectByEmailAndAcceptInviteViaEmail(projectName, email, 'Viewer')
})
it('should grant the collaborator read access', () => {
@ -169,7 +169,7 @@ describe('Project Sharing', function () {
beforeWithReRunOnTestRetry(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Can view')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Viewer')
})
it('should grant the collaborator read access', () => {
@ -186,7 +186,7 @@ describe('Project Sharing', function () {
beforeWithReRunOnTestRetry(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Can edit')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Editor')
})
it('should grant the collaborator write access', () => {

View file

@ -204,9 +204,9 @@ describe('SandboxedCompiles', function () {
cy.log('wait for compile')
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
cy.log('Check which compiler version was used, expect 2024')
cy.log('Check which compiler version was used, expect 2025')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2024\) /)
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
cy.log('Check that there is no TeX Live version toggle')
cy.get('header').findByText('Menu').click()

View file

@ -116,13 +116,6 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) up -d mongo
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) exec -T mongo sh -c ' \
while ! mongosh --eval "db.version()" > /dev/null; do \
echo "Waiting for Mongo..."; \
sleep 1; \
done; \
mongosh --eval "rs.initiate({ _id: \"overleaf\", members: [ { _id: 0, host: \"mongo:27017\" } ] })"'
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run
endif

View file

@ -26,7 +26,7 @@ services:
NODE_OPTIONS: "--unhandled-rejections=strict"
depends_on:
mongo:
condition: service_healthy
condition: service_started
user: node
command: npm run test:acceptance
@ -41,7 +41,12 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -38,14 +38,19 @@ services:
user: node
depends_on:
mongo:
condition: service_healthy
condition: service_started
command: npm run --silent test:acceptance
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -309,6 +309,10 @@ const loadTcpServer = net.createServer(function (socket) {
} else {
// Ready will cancel the maint state.
socket.write(`up, ready, ${Math.max(freeLoadPercentage, 1)}%\n`, 'ASCII')
if (freeLoadPercentage <= 0) {
// This metric records how often we would have gone into maintenance mode.
Metrics.inc('clsi-prevented-maint')
}
}
socket.end()
} else {

View file

@ -98,12 +98,11 @@ module.exports = OutputCacheManager = {
CONTENT_SUBDIR: 'content',
CACHE_SUBDIR: 'generated-files',
ARCHIVE_SUBDIR: 'archived-logs',
// build id is HEXDATE-HEXRANDOM from Date.now()and RandomBytes
// for backwards compatibility, make the randombytes part optional
BUILD_REGEX: /^[0-9a-f]+(-[0-9a-f]+)?$/,
CONTENT_REGEX: /^[0-9a-f]+(-[0-9a-f]+)?$/,
// build id is HEXDATE-HEXRANDOM from Date.now() and RandomBytes
BUILD_REGEX: /^[0-9a-f]+-[0-9a-f]+$/,
CONTENT_REGEX: /^[0-9a-f]+-[0-9a-f]+$/,
CACHE_LIMIT: 2, // maximum number of cache directories
CACHE_AGE: 60 * 60 * 1000, // up to one hour old
CACHE_AGE: 90 * 60 * 1000, // up to 90 minutes old
init,
queueDirOperation: callbackify(queueDirOperation),
@ -137,7 +136,11 @@ module.exports = OutputCacheManager = {
outputDir,
callback
) {
OutputCacheManager.generateBuildId(function (err, buildId) {
const getBuildId = cb => {
if (request.buildId) return cb(null, request.buildId)
OutputCacheManager.generateBuildId(cb)
}
getBuildId(function (err, buildId) {
if (err) {
return callback(err)
}

View file

@ -7,7 +7,7 @@ const { NotFoundError } = require('./Errors')
const logger = require('@overleaf/logger')
// NOTE: Updating this list requires a corresponding change in
// * services/web/frontend/js/features/pdf-preview/util/file-list.js
// * services/web/frontend/js/features/pdf-preview/util/file-list.ts
const ignoreFiles = ['output.fls', 'output.fdb_latexmk']
function getContentDir(projectId, userId) {

View file

@ -13,6 +13,7 @@ const CompileManager = require('./CompileManager')
const async = require('async')
const logger = require('@overleaf/logger')
const oneDay = 24 * 60 * 60 * 1000
const Metrics = require('@overleaf/metrics')
const Settings = require('@overleaf/settings')
const diskusage = require('diskusage')
const { callbackify } = require('node:util')
@ -22,33 +23,48 @@ const fs = require('node:fs')
// projectId -> timestamp mapping.
const LAST_ACCESS = new Map()
async function refreshExpiryTimeout() {
async function collectDiskStats() {
const paths = [
Settings.path.compilesDir,
Settings.path.outputDir,
Settings.path.clsiCacheDir,
]
const diskStats = {}
for (const path of paths) {
try {
const stats = await diskusage.check(path)
const lowDisk = stats.available / stats.total < 0.1
const lowerExpiry = ProjectPersistenceManager.EXPIRY_TIMEOUT * 0.9
if (lowDisk && Settings.project_cache_length_ms / 2 < lowerExpiry) {
logger.warn(
{
stats,
newExpiryTimeoutInDays: (lowerExpiry / oneDay).toFixed(2),
},
'disk running low on space, modifying EXPIRY_TIMEOUT'
)
ProjectPersistenceManager.EXPIRY_TIMEOUT = lowerExpiry
break
}
const diskAvailablePercent = (stats.available / stats.total) * 100
Metrics.gauge('disk_available_percent', diskAvailablePercent, 1, {
path,
})
const lowDisk = diskAvailablePercent < 10
diskStats[path] = { stats, lowDisk }
} catch (err) {
logger.err({ err, path }, 'error getting disk usage')
}
}
return diskStats
}
async function refreshExpiryTimeout() {
for (const [path, { stats, lowDisk }] of Object.entries(
await collectDiskStats()
)) {
const lowerExpiry = ProjectPersistenceManager.EXPIRY_TIMEOUT * 0.9
if (lowDisk && Settings.project_cache_length_ms / 2 < lowerExpiry) {
logger.warn(
{
path,
stats,
newExpiryTimeoutInDays: (lowerExpiry / oneDay).toFixed(2),
},
'disk running low on space, modifying EXPIRY_TIMEOUT'
)
ProjectPersistenceManager.EXPIRY_TIMEOUT = lowerExpiry
break
}
}
}
module.exports = ProjectPersistenceManager = {
@ -103,6 +119,13 @@ module.exports = ProjectPersistenceManager = {
}
)
})
// Collect disk stats frequently to have them ready the next time /metrics is scraped (60s +- jitter).
setInterval(() => {
collectDiskStats().catch(err => {
logger.err({ err }, 'low level error collecting disk stats')
})
}, 50_000)
},
markProjectAsJustAccessed(projectId, callback) {

View file

@ -1,4 +1,5 @@
const settings = require('@overleaf/settings')
const OutputCacheManager = require('./OutputCacheManager')
const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
const MAX_TIMEOUT = 600
@ -135,6 +136,11 @@ function parse(body, callback) {
}
)
response.rootResourcePath = _checkPath(rootResourcePath)
response.buildId = _parseAttribute('buildId', compile.options.buildId, {
type: 'string',
regex: OutputCacheManager.BUILD_REGEX,
})
} catch (error1) {
const error = error1
return callback(error)
@ -199,6 +205,13 @@ function _parseAttribute(name, attribute, options) {
throw new Error(`${name} attribute should be a ${options.type}`)
}
}
if (options.type === 'string' && options.regex instanceof RegExp) {
if (!options.regex.test(attribute)) {
throw new Error(
`${name} attribute does not match regex ${options.regex}`
)
}
}
} else {
if (options.default != null) {
return options.default

View file

@ -200,73 +200,22 @@ module.exports = ResourceWriter = {
return OutputFileFinder.findOutputFiles(
resources,
basePath,
function (error, outputFiles, allFiles) {
(error, outputFiles, allFiles) => {
if (error != null) {
return callback(error)
}
const jobs = []
for (const file of Array.from(outputFiles || [])) {
;(function (file) {
const { path } = file
let shouldDelete = true
if (
path.match(/^output\./) ||
path.match(/\.aux$/) ||
path.match(/^cache\//)
) {
// knitr cache
shouldDelete = false
}
if (path.match(/^output-.*/)) {
// Tikz cached figures (default case)
shouldDelete = false
}
if (path.match(/\.(pdf|dpth|md5)$/)) {
// Tikz cached figures (by extension)
shouldDelete = false
}
if (
path.match(/\.(pygtex|pygstyle)$/) ||
path.match(/(^|\/)_minted-[^\/]+\//)
) {
// minted files/directory
shouldDelete = false
}
if (
path.match(/\.md\.tex$/) ||
path.match(/(^|\/)_markdown_[^\/]+\//)
) {
// markdown files/directory
shouldDelete = false
}
if (path.match(/-eps-converted-to\.pdf$/)) {
// Epstopdf generated files
shouldDelete = false
}
if (
path === 'output.pdf' ||
path === 'output.dvi' ||
path === 'output.log' ||
path === 'output.xdv' ||
path === 'output.stdout' ||
path === 'output.stderr'
) {
shouldDelete = true
}
if (path === 'output.tex') {
// created by TikzManager if present in output files
shouldDelete = true
}
if (shouldDelete) {
return jobs.push(callback =>
ResourceWriter._deleteFileIfNotDirectory(
Path.join(basePath, path),
callback
)
for (const { path } of outputFiles || []) {
const shouldDelete = ResourceWriter.isExtraneousFile(path)
if (shouldDelete) {
jobs.push(callback =>
ResourceWriter._deleteFileIfNotDirectory(
Path.join(basePath, path),
callback
)
}
})(file)
)
}
}
return async.series(jobs, function (error) {
@ -279,6 +228,58 @@ module.exports = ResourceWriter = {
)
},
isExtraneousFile(path) {
let shouldDelete = true
if (
path.match(/^output\./) ||
path.match(/\.aux$/) ||
path.match(/^cache\//)
) {
// knitr cache
shouldDelete = false
}
if (path.match(/^output-.*/)) {
// Tikz cached figures (default case)
shouldDelete = false
}
if (path.match(/\.(pdf|dpth|md5)$/)) {
// Tikz cached figures (by extension)
shouldDelete = false
}
if (
path.match(/\.(pygtex|pygstyle)$/) ||
path.match(/(^|\/)_minted-[^\/]+\//)
) {
// minted files/directory
shouldDelete = false
}
if (path.match(/\.md\.tex$/) || path.match(/(^|\/)_markdown_[^\/]+\//)) {
// markdown files/directory
shouldDelete = false
}
if (path.match(/-eps-converted-to\.pdf$/)) {
// Epstopdf generated files
shouldDelete = false
}
if (
path === 'output.synctex.gz' ||
path === 'output.pdfxref' ||
path === 'output.pdf' ||
path === 'output.dvi' ||
path === 'output.log' ||
path === 'output.xdv' ||
path === 'output.stdout' ||
path === 'output.stderr'
) {
shouldDelete = true
}
if (path === 'output.tex') {
// created by TikzManager if present in output files
shouldDelete = true
}
return shouldDelete
},
_deleteFileIfNotDirectory(path, callback) {
if (callback == null) {
callback = function () {}

View file

@ -46,7 +46,7 @@ server {
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
if ($request_method = 'OPTIONS') {
# handle OPTIONS method for CORS requests
add_header 'Allow' 'GET,HEAD';
@ -64,7 +64,7 @@ server {
alias /output/$1-$2/generated-files/$3/$4.blg;
}
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z]+)$ {
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
if ($request_method = 'OPTIONS') {
# handle OPTIONS method for CORS requests
add_header 'Allow' 'GET,HEAD';

View file

@ -28,7 +28,7 @@
"body-parser": "^1.20.3",
"bunyan": "^1.8.15",
"diskusage": "^1.1.3",
"dockerode": "^3.1.0",
"dockerode": "^4.0.5",
"express": "^4.21.2",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",

View file

@ -107,7 +107,6 @@ Hello world
'output.fdb_latexmk',
'output.fls',
'output.log',
'output.pdfxref',
'output.stderr',
'output.stdout',
])

View file

@ -16,7 +16,7 @@ const modulePath = require('node:path').join(
'../../../app/js/DockerLockManager'
)
describe('LockManager', function () {
describe('DockerLockManager', function () {
beforeEach(function () {
return (this.LockManager = SandboxedModule.require(modulePath, {
requires: {

View file

@ -21,6 +21,7 @@ describe('LockManager', function () {
compileConcurrencyLimit: 5,
}),
'./Errors': (this.Erros = Errors),
'./RequestParser': { MAX_TIMEOUT: 600 },
},
})
})

View file

@ -23,6 +23,7 @@ describe('ProjectPersistenceManager', function () {
beforeEach(function () {
this.ProjectPersistenceManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/metrics': (this.Metrics = { gauge: sinon.stub() }),
'./UrlCache': (this.UrlCache = {}),
'./CompileManager': (this.CompileManager = {}),
diskusage: (this.diskusage = { check: sinon.stub() }),
@ -49,6 +50,10 @@ describe('ProjectPersistenceManager', function () {
})
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
this.Metrics.gauge.should.have.been.calledWith(
'disk_available_percent',
40
)
this.ProjectPersistenceManager.EXPIRY_TIMEOUT.should.equal(
this.settings.project_cache_length_ms
)
@ -63,6 +68,10 @@ describe('ProjectPersistenceManager', function () {
})
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
this.Metrics.gauge.should.have.been.calledWith(
'disk_available_percent',
5
)
this.ProjectPersistenceManager.EXPIRY_TIMEOUT.should.equal(900)
done()
})
@ -75,6 +84,10 @@ describe('ProjectPersistenceManager', function () {
})
this.ProjectPersistenceManager.EXPIRY_TIMEOUT = 500
this.ProjectPersistenceManager.refreshExpiryTimeout(() => {
this.Metrics.gauge.should.have.been.calledWith(
'disk_available_percent',
5
)
this.ProjectPersistenceManager.EXPIRY_TIMEOUT.should.equal(500)
done()
})

View file

@ -30,6 +30,7 @@ describe('RequestParser', function () {
this.RequestParser = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'./OutputCacheManager': { BUILD_REGEX: /^[0-9a-f]+-[0-9a-f]+$/ },
},
})
})
@ -274,6 +275,37 @@ describe('RequestParser', function () {
})
})
describe('with a valid buildId', function () {
beforeEach(function (done) {
this.validRequest.compile.options.buildId = '195a4869176-a4ad60bee7bf35e4'
this.RequestParser.parse(this.validRequest, (error, data) => {
if (error) return done(error)
this.data = data
done()
})
})
it('should return an error', function () {
this.data.buildId.should.equal('195a4869176-a4ad60bee7bf35e4')
})
})
describe('with a bad buildId', function () {
beforeEach(function () {
this.validRequest.compile.options.buildId = 'foo/bar'
this.RequestParser.parse(this.validRequest, this.callback)
})
it('should return an error', function () {
this.callback
.calledWithMatch({
message:
'buildId attribute does not match regex /^[0-9a-f]+-[0-9a-f]+$/',
})
.should.equal(true)
})
})
describe('with a resource with a valid date', function () {
beforeEach(function () {
this.date = '12:00 01/02/03'

View file

@ -116,13 +116,6 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) up -d mongo
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) exec -T mongo sh -c ' \
while ! mongosh --eval "db.version()" > /dev/null; do \
echo "Waiting for Mongo..."; \
sleep 1; \
done; \
mongosh --eval "rs.initiate({ _id: \"overleaf\", members: [ { _id: 0, host: \"mongo:27017\" } ] })"'
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run
endif

View file

@ -26,7 +26,7 @@ services:
NODE_OPTIONS: "--unhandled-rejections=strict"
depends_on:
mongo:
condition: service_healthy
condition: service_started
user: node
command: npm run test:acceptance
@ -41,7 +41,12 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -38,14 +38,19 @@ services:
user: node
depends_on:
mongo:
condition: service_healthy
condition: service_started
command: npm run --silent test:acceptance
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -116,13 +116,6 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) up -d mongo
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) exec -T mongo sh -c ' \
while ! mongosh --eval "db.version()" > /dev/null; do \
echo "Waiting for Mongo..."; \
sleep 1; \
done; \
mongosh --eval "rs.initiate({ _id: \"overleaf\", members: [ { _id: 0, host: \"mongo:27017\" } ] })"'
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run
endif

View file

@ -88,14 +88,17 @@ app.get('/status', (req, res) => res.send('docstore is alive'))
app.use(handleValidationErrors())
app.use(function (error, req, res, next) {
logger.error({ err: error, req }, 'request errored')
if (error instanceof Errors.NotFoundError) {
logger.warn({ req }, 'not found')
res.sendStatus(404)
} else if (error instanceof Errors.DocModifiedError) {
logger.warn({ req }, 'conflict: doc modified')
res.sendStatus(409)
} else if (error instanceof Errors.DocVersionDecrementedError) {
logger.warn({ req }, 'conflict: doc version decremented')
res.sendStatus(409)
} else {
logger.error({ err: error, req }, 'request errored')
res.status(500).send('Oops, something went wrong')
}
})

View file

@ -29,7 +29,7 @@ services:
NODE_OPTIONS: "--unhandled-rejections=strict"
depends_on:
mongo:
condition: service_healthy
condition: service_started
gcs:
condition: service_healthy
user: node
@ -46,10 +46,15 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
gcs:
image: fsouza/fake-gcs-server:1.45.2
command: ["--port=9090", "--scheme=http"]

View file

@ -41,7 +41,7 @@ services:
user: node
depends_on:
mongo:
condition: service_healthy
condition: service_started
gcs:
condition: service_healthy
command: npm run --silent test:acceptance
@ -49,10 +49,15 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
gcs:
image: fsouza/fake-gcs-server:1.45.2

View file

@ -116,13 +116,6 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) up -d mongo
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) exec -T mongo sh -c ' \
while ! mongosh --eval "db.version()" > /dev/null; do \
echo "Waiting for Mongo..."; \
sleep 1; \
done; \
mongosh --eval "rs.initiate({ _id: \"overleaf\", members: [ { _id: 0, host: \"mongo:27017\" } ] })"'
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run
endif

View file

@ -147,6 +147,10 @@ app.post(
'/project/:project_id/get_and_flush_if_old',
HttpController.getProjectDocsAndFlushIfOld
)
app.get(
'/project/:project_id/last_updated_at',
HttpController.getProjectLastUpdatedAt
)
app.post('/project/:project_id/clearState', HttpController.clearProjectState)
app.post('/project/:project_id/doc/:doc_id', HttpController.setDoc)
app.post('/project/:project_id/doc/:doc_id/append', HttpController.appendToDoc)

View file

@ -129,6 +129,22 @@ function getProjectDocsAndFlushIfOld(req, res, next) {
)
}
function getProjectLastUpdatedAt(req, res, next) {
const projectId = req.params.project_id
ProjectManager.getProjectDocsTimestamps(projectId, (err, timestamps) => {
if (err) return next(err)
// Filter out nulls. This can happen when
// - docs get flushed between the listing and getting the individual docs ts
// - a doc flush failed half way (doc keys removed, project tracking not updated)
timestamps = timestamps.filter(ts => !!ts)
timestamps = timestamps.map(ts => parseInt(ts, 10))
timestamps.sort((a, b) => (a > b ? 1 : -1))
res.json({ lastUpdatedAt: timestamps.pop() })
})
}
function clearProjectState(req, res, next) {
const projectId = req.params.project_id
const timer = new Metrics.Timer('http.clearProjectState')
@ -521,6 +537,7 @@ module.exports = {
getDoc,
peekDoc,
getProjectDocsAndFlushIfOld,
getProjectLastUpdatedAt,
clearProjectState,
appendToDoc,
setDoc,

View file

@ -29,7 +29,7 @@ services:
NODE_OPTIONS: "--unhandled-rejections=strict"
depends_on:
mongo:
condition: service_healthy
condition: service_started
redis:
condition: service_healthy
user: node
@ -53,7 +53,12 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -41,7 +41,7 @@ services:
user: node
depends_on:
mongo:
condition: service_healthy
condition: service_started
redis:
condition: service_healthy
command: npm run --silent test:acceptance
@ -56,8 +56,13 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

View file

@ -109,11 +109,40 @@ describe('Applying updates to a doc', function () {
)
})
it('should yield last updated time', function (done) {
DocUpdaterClient.getProjectLastUpdatedAt(
this.project_id,
(error, res, body) => {
if (error != null) {
throw error
}
res.statusCode.should.equal(200)
body.lastUpdatedAt.should.be.within(this.startTime, Date.now())
done()
}
)
})
it('should yield no last updated time for another project', function (done) {
DocUpdaterClient.getProjectLastUpdatedAt(
DocUpdaterClient.randomId(),
(error, res, body) => {
if (error != null) {
throw error
}
res.statusCode.should.equal(200)
body.should.deep.equal({})
done()
}
)
})
describe('when sending another update', function () {
before(function (done) {
this.timeout = 10000
this.second_update = Object.create(this.update)
this.timeout(10000)
this.second_update = Object.assign({}, this.update)
this.second_update.v = this.version + 1
this.secondStartTime = Date.now()
DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
@ -127,6 +156,24 @@ describe('Applying updates to a doc', function () {
)
})
it('should update the doc', function (done) {
DocUpdaterClient.getDoc(
this.project_id,
this.doc_id,
(error, res, doc) => {
if (error) done(error)
doc.lines.should.deep.equal([
'one',
'one and a half',
'one and a half',
'two',
'three',
])
done()
}
)
})
it('should not change the first op timestamp', function (done) {
rclientProjectHistory.get(
ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
@ -142,6 +189,23 @@ describe('Applying updates to a doc', function () {
}
)
})
it('should yield last updated time', function (done) {
DocUpdaterClient.getProjectLastUpdatedAt(
this.project_id,
(error, res, body) => {
if (error != null) {
throw error
}
res.statusCode.should.equal(200)
body.lastUpdatedAt.should.be.within(
this.secondStartTime,
Date.now()
)
done()
}
)
})
})
})

View file

@ -119,6 +119,18 @@ module.exports = DocUpdaterClient = {
)
},
getProjectLastUpdatedAt(projectId, callback) {
request.get(
`http://127.0.0.1:3003/project/${projectId}/last_updated_at`,
(error, res, body) => {
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
body = JSON.parse(body)
}
callback(error, res, body)
}
)
},
preloadDoc(projectId, docId, callback) {
DocUpdaterClient.getDoc(projectId, docId, callback)
},

View file

@ -76,12 +76,10 @@ The configuration file is in `.json` format.
"postbackBaseUrl" (string): the postback url,
"serviceName" (string): current name of writeLaTeX
in case it ever changes,
"oauth2" (object): { null or missing if oauth2 shouldn't be used
"oauth2ClientID" (string): oauth2 client ID,
"oauth2ClientSecret" (string): oauth2 client secret,
"oauth2Server" (string): oauth2 server,
with protocol and
without trailing slash
"oauth2Server" (string): oauth2 server,
with protocol and
without trailing slash,
null or missing if oauth2 shouldn't be used
},
"repoStore" (object, optional): { configure the repo store
"maxFileSize" (long, optional): maximum size of a file, inclusive

View file

@ -7,11 +7,7 @@
"apiBaseUrl": "${GIT_BRIDGE_API_BASE_URL:-https://localhost/api/v0}",
"postbackBaseUrl": "${GIT_BRIDGE_POSTBACK_BASE_URL:-https://localhost}",
"serviceName": "${GIT_BRIDGE_SERVICE_NAME:-Overleaf}",
"oauth2": {
"oauth2ClientID": "${GIT_BRIDGE_OAUTH2_CLIENT_ID}",
"oauth2ClientSecret": "${GIT_BRIDGE_OAUTH2_CLIENT_SECRET}",
"oauth2Server": "${GIT_BRIDGE_OAUTH2_SERVER:-https://localhost}"
},
"oauth2Server": "${GIT_BRIDGE_OAUTH2_SERVER:-https://localhost}",
"userPasswordEnabled": ${GIT_BRIDGE_USER_PASSWORD_ENABLED:-false},
"repoStore": {
"maxFileNum": ${GIT_BRIDGE_REPOSTORE_MAX_FILE_NUM:-2000},

View file

@ -7,11 +7,7 @@
"apiBaseUrl": "https://localhost/api/v0",
"postbackBaseUrl": "https://localhost",
"serviceName": "Overleaf",
"oauth2": {
"oauth2ClientID": "asdf",
"oauth2ClientSecret": "asdf",
"oauth2Server": "https://localhost"
},
"oauth2Server": "https://localhost",
"repoStore": {
"maxFileNum": 2000,
"maxFileSize": 52428800

View file

@ -7,11 +7,7 @@
"apiBaseUrl": "http://v2.overleaf.test:3000/api/v0",
"postbackBaseUrl": "http://git-bridge:8000",
"serviceName": "Overleaf",
"oauth2": {
"oauth2ClientID": "264c723c925c13590880751f861f13084934030c13b4452901e73bdfab226edc",
"oauth2ClientSecret": "e6b2e9eee7ae2bb653823250bb69594a91db0547fe3790a7135acb497108e62d",
"oauth2Server": "http://v2.overleaf.test:3000"
},
"oauth2Server": "http://v2.overleaf.test:3000",
"repoStore": {
"maxFileNum": 2000,
"maxFileSize": 52428800

View file

@ -30,7 +30,7 @@ public class Config implements JSONSource {
config.apiBaseURL,
config.postbackURL,
config.serviceName,
Oauth2.asSanitised(config.oauth2),
config.oauth2Server,
config.userPasswordEnabled,
config.repoStore,
SwapStoreConfig.sanitisedCopy(config.swapStore),
@ -46,7 +46,7 @@ public class Config implements JSONSource {
private String apiBaseURL;
private String postbackURL;
private String serviceName;
@Nullable private Oauth2 oauth2;
@Nullable private String oauth2Server;
private boolean userPasswordEnabled;
@Nullable private RepoStoreConfig repoStore;
@Nullable private SwapStoreConfig swapStore;
@ -70,7 +70,7 @@ public class Config implements JSONSource {
String apiBaseURL,
String postbackURL,
String serviceName,
Oauth2 oauth2,
String oauth2Server,
boolean userPasswordEnabled,
RepoStoreConfig repoStore,
SwapStoreConfig swapStore,
@ -84,7 +84,7 @@ public class Config implements JSONSource {
this.apiBaseURL = apiBaseURL;
this.postbackURL = postbackURL;
this.serviceName = serviceName;
this.oauth2 = oauth2;
this.oauth2Server = oauth2Server;
this.userPasswordEnabled = userPasswordEnabled;
this.repoStore = repoStore;
this.swapStore = swapStore;
@ -116,7 +116,7 @@ public class Config implements JSONSource {
if (!postbackURL.endsWith("/")) {
postbackURL += "/";
}
oauth2 = new Gson().fromJson(configObject.get("oauth2"), Oauth2.class);
oauth2Server = getOptionalString(configObject, "oauth2Server");
userPasswordEnabled = getOptionalString(configObject, "userPasswordEnabled").equals("true");
repoStore = new Gson().fromJson(configObject.get("repoStore"), RepoStoreConfig.class);
swapStore = new Gson().fromJson(configObject.get("swapStore"), SwapStoreConfig.class);
@ -166,19 +166,12 @@ public class Config implements JSONSource {
return postbackURL;
}
public boolean isUsingOauth2() {
return oauth2 != null;
}
public boolean isUserPasswordEnabled() {
return userPasswordEnabled;
}
public Oauth2 getOauth2() {
if (!isUsingOauth2()) {
throw new AssertionError("Getting oauth2 when not using it");
}
return oauth2;
public String getOauth2Server() {
return oauth2Server;
}
public Optional<RepoStoreConfig> getRepoStore() {

View file

@ -1,33 +0,0 @@
package uk.ac.ic.wlgitbridge.application.config;
/*
* Created by winston on 25/10/15.
*/
public class Oauth2 {
private final String oauth2ClientID;
private final String oauth2ClientSecret;
private final String oauth2Server;
public Oauth2(String oauth2ClientID, String oauth2ClientSecret, String oauth2Server) {
this.oauth2ClientID = oauth2ClientID;
this.oauth2ClientSecret = oauth2ClientSecret;
this.oauth2Server = oauth2Server;
}
public String getOauth2ClientID() {
return oauth2ClientID;
}
public String getOauth2ClientSecret() {
return oauth2ClientSecret;
}
public String getOauth2Server() {
return oauth2Server;
}
public static Oauth2 asSanitised(Oauth2 oauth2) {
return new Oauth2("<oauth2ClientID>", "<oauth2ClientSecret>", oauth2.oauth2Server);
}
}

View file

@ -151,9 +151,9 @@ public class GitBridgeServer {
throws ServletException {
final ServletContextHandler servletContextHandler =
new ServletContextHandler(ServletContextHandler.SESSIONS);
if (config.isUsingOauth2()) {
if (config.getOauth2Server() != null) {
Filter filter =
new Oauth2Filter(snapshotApi, config.getOauth2(), config.isUserPasswordEnabled());
new Oauth2Filter(snapshotApi, config.getOauth2Server(), config.isUserPasswordEnabled());
servletContextHandler.addFilter(
new FilterHolder(filter), "/*", EnumSet.of(DispatcherType.REQUEST));
}

View file

@ -13,7 +13,6 @@ import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.codec.binary.Base64;
import uk.ac.ic.wlgitbridge.application.config.Oauth2;
import uk.ac.ic.wlgitbridge.bridge.snapshot.SnapshotApi;
import uk.ac.ic.wlgitbridge.util.Instance;
import uk.ac.ic.wlgitbridge.util.Log;
@ -28,13 +27,13 @@ public class Oauth2Filter implements Filter {
private final SnapshotApi snapshotApi;
private final Oauth2 oauth2;
private final String oauth2Server;
private final boolean isUserPasswordEnabled;
public Oauth2Filter(SnapshotApi snapshotApi, Oauth2 oauth2, boolean isUserPasswordEnabled) {
public Oauth2Filter(SnapshotApi snapshotApi, String oauth2Server, boolean isUserPasswordEnabled) {
this.snapshotApi = snapshotApi;
this.oauth2 = oauth2;
this.oauth2Server = oauth2Server;
this.isUserPasswordEnabled = isUserPasswordEnabled;
}
@ -108,7 +107,7 @@ public class Oauth2Filter implements Filter {
// fail later (for example, in the unlikely event that the token
// expired between the two requests). In that case, JGit will
// return a 401 without a custom error message.
int statusCode = checkAccessToken(oauth2, password, getClientIp(request));
int statusCode = checkAccessToken(this.oauth2Server, password, getClientIp(request));
if (statusCode == 429) {
handleRateLimit(projectId, username, request, response);
return;
@ -238,10 +237,9 @@ public class Oauth2Filter implements Filter {
"your Overleaf Account Settings."));
}
private int checkAccessToken(Oauth2 oauth2, String accessToken, String clientIp)
private int checkAccessToken(String oauth2Server, String accessToken, String clientIp)
throws IOException {
GenericUrl url =
new GenericUrl(oauth2.getOauth2Server() + "/oauth/token/info?client_ip=" + clientIp);
GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
HttpHeaders headers = new HttpHeaders();
headers.setAuthorization("Bearer " + accessToken);

View file

@ -1495,13 +1495,9 @@ public class WLGitBridgeIntegrationTest {
+ port
+ "\",\n"
+ " \"serviceName\": \"Overleaf\",\n"
+ " \"oauth2\": {\n"
+ " \"oauth2ClientID\": \"clientID\",\n"
+ " \"oauth2ClientSecret\": \"oauth2 client secret\",\n"
+ " \"oauth2Server\": \"http://127.0.0.1:"
+ " \"oauth2Server\": \"http://127.0.0.1:"
+ apiPort
+ "\"\n"
+ " }";
+ "\"";
if (swapCfg != null) {
cfgStr +=
",\n"
@ -1524,7 +1520,6 @@ public class WLGitBridgeIntegrationTest {
+ ",\n"
+ " \"intervalMillis\": "
+ swapCfg.getIntervalMillis()
+ "\n"
+ " }\n";
}
cfgStr += "}\n";

View file

@ -23,11 +23,7 @@ public class ConfigTest {
+ " \"apiBaseUrl\": \"http://127.0.0.1:60000/api/v0\",\n"
+ " \"postbackBaseUrl\": \"http://127.0.0.1\",\n"
+ " \"serviceName\": \"Overleaf\",\n"
+ " \"oauth2\": {\n"
+ " \"oauth2ClientID\": \"clientID\",\n"
+ " \"oauth2ClientSecret\": \"oauth2 client secret\",\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\"\n"
+ " }\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\"\n"
+ "}\n");
Config config = new Config(reader);
assertEquals(80, config.getPort());
@ -35,10 +31,7 @@ public class ConfigTest {
assertEquals("http://127.0.0.1:60000/api/v0/", config.getAPIBaseURL());
assertEquals("http://127.0.0.1/", config.getPostbackURL());
assertEquals("Overleaf", config.getServiceName());
assertTrue(config.isUsingOauth2());
assertEquals("clientID", config.getOauth2().getOauth2ClientID());
assertEquals("oauth2 client secret", config.getOauth2().getOauth2ClientSecret());
assertEquals("https://www.overleaf.com", config.getOauth2().getOauth2Server());
assertEquals("https://www.overleaf.com", config.getOauth2Server());
}
@Test(expected = AssertionError.class)
@ -53,7 +46,7 @@ public class ConfigTest {
+ " \"apiBaseUrl\": \"http://127.0.0.1:60000/api/v0\",\n"
+ " \"postbackBaseUrl\": \"http://127.0.0.1\",\n"
+ " \"serviceName\": \"Overleaf\",\n"
+ " \"oauth2\": null\n"
+ " \"oauth2Server\": null\n"
+ "}\n");
Config config = new Config(reader);
assertEquals(80, config.getPort());
@ -61,8 +54,7 @@ public class ConfigTest {
assertEquals("http://127.0.0.1:60000/api/v0/", config.getAPIBaseURL());
assertEquals("http://127.0.0.1/", config.getPostbackURL());
assertEquals("Overleaf", config.getServiceName());
assertFalse(config.isUsingOauth2());
config.getOauth2();
assertNull(config.getOauth2Server());
}
@Test
@ -77,11 +69,7 @@ public class ConfigTest {
+ " \"apiBaseUrl\": \"http://127.0.0.1:60000/api/v0\",\n"
+ " \"postbackBaseUrl\": \"http://127.0.0.1\",\n"
+ " \"serviceName\": \"Overleaf\",\n"
+ " \"oauth2\": {\n"
+ " \"oauth2ClientID\": \"my oauth2 client id\",\n"
+ " \"oauth2ClientSecret\": \"my oauth2 client secret\",\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\"\n"
+ " }\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\"\n"
+ "}\n");
Config config = new Config(reader);
String expected =
@ -94,11 +82,7 @@ public class ConfigTest {
+ " \"apiBaseURL\": \"http://127.0.0.1:60000/api/v0/\",\n"
+ " \"postbackURL\": \"http://127.0.0.1/\",\n"
+ " \"serviceName\": \"Overleaf\",\n"
+ " \"oauth2\": {\n"
+ " \"oauth2ClientID\": \"<oauth2ClientID>\",\n"
+ " \"oauth2ClientSecret\": \"<oauth2ClientSecret>\",\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\"\n"
+ " },\n"
+ " \"oauth2Server\": \"https://www.overleaf.com\",\n"
+ " \"userPasswordEnabled\": false,\n"
+ " \"repoStore\": null,\n"
+ " \"swapStore\": null,\n"

View file

@ -116,13 +116,6 @@ test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) up -d mongo
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) exec -T mongo sh -c ' \
while ! mongosh --eval "db.version()" > /dev/null; do \
echo "Waiting for Mongo..."; \
sleep 1; \
done; \
mongosh --eval "rs.initiate({ _id: \"overleaf\", members: [ { _id: 0, host: \"mongo:27017\" } ] })"'
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run
endif

View file

@ -22,6 +22,7 @@ const BlobStore = storage.BlobStore
const chunkStore = storage.chunkStore
const HashCheckBlobStore = storage.HashCheckBlobStore
const persistChanges = storage.persistChanges
const InvalidChangeError = storage.InvalidChangeError
const render = require('./render')
@ -113,7 +114,8 @@ async function importChanges(req, res, next) {
err instanceof File.NotEditableError ||
err instanceof FileMap.PathnameError ||
err instanceof Snapshot.EditMissingFileError ||
err instanceof chunkStore.ChunkVersionConflictError
err instanceof chunkStore.ChunkVersionConflictError ||
err instanceof InvalidChangeError
) {
// If we failed to apply operations, that's probably because they were
// invalid.

View file

@ -4,17 +4,24 @@ import '@overleaf/metrics/initialize.js'
import http from 'node:http'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { setTimeout } from 'node:timers/promises'
import express from 'express'
import logger from '@overleaf/logger'
import Metrics from '@overleaf/metrics'
import { healthCheck } from './backupVerifier/healthCheck.mjs'
import {
BackupCorruptedError,
healthCheck,
verifyBlob,
} from './storage/lib/backupVerifier.mjs'
import { mongodb } from './storage/index.js'
import { expressify } from '@overleaf/promise-utils'
import { Blob } from 'overleaf-editor-core'
import { loadGlobalBlobs } from './storage/lib/blob_store/index.js'
import { EventEmitter } from 'node:events'
import {
loopRandomProjects,
setWriteMetrics,
} from './backupVerifier/ProjectVerifier.mjs'
const app = express()
@ -64,20 +71,46 @@ app.use((err, req, res, next) => {
next(err)
})
const shutdownEmitter = new EventEmitter()
shutdownEmitter.once('shutdown', async code => {
logger.info({ code }, 'shutting down')
await mongodb.client.close()
await setTimeout(100)
process.exit(code)
})
process.on('SIGTERM', () => {
shutdownEmitter.emit('shutdown', 0)
})
process.on('SIGINT', () => {
shutdownEmitter.emit('shutdown', 0)
})
/**
* @param {number} port
* @return {Promise<http.Server>}
*/
export async function startApp(port) {
await mongodb.client.connect()
await loadGlobalBlobs()
await healthCheck()
const server = http.createServer(app)
await promisify(server.listen.bind(server, port))()
loopRandomProjects(shutdownEmitter)
return server
}
setWriteMetrics(true)
// Run this if we're called directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const PORT = parseInt(process.env.PORT || '3102', 10)
await startApp(PORT)
try {
await startApp(PORT)
} catch (error) {
shutdownEmitter.emit('shutdown', 1)
logger.error({ error }, 'error starting app')
}
}

View file

@ -38,10 +38,10 @@ app.use((err, req, res, next) => {
})
async function triggerGracefulShutdown(server, signal) {
logger.warn({ signal }, 'graceful shutdown: started shutdown sequence')
logger.info({ signal }, 'graceful shutdown: started shutdown sequence')
await drainQueue()
server.close(function () {
logger.warn({ signal }, 'graceful shutdown: closed server')
logger.info({ signal }, 'graceful shutdown: closed server')
setTimeout(() => {
process.exit(0)
}, 1000)

View file

@ -0,0 +1,33 @@
import Metrics from '@overleaf/metrics'
import { objectIdFromDate } from './utils.mjs'
import { db } from '../storage/lib/mongodb.js'
const projectsCollection = db.collection('projects')
/**
*
* @param {Date} beforeTime
* @return {Promise<void>}
*/
export async function measurePendingChangesBeforeTime(beforeTime) {
const pendingChangeCount = await projectsCollection.countDocuments({
'overleaf.backup.pendingChangeAt': {
$lt: beforeTime,
},
})
Metrics.gauge('backup_verification_pending_changes', pendingChangeCount)
}
/**
*
* @param {Date} graceTime
* @return {Promise<void>}
*/
export async function measureNeverBackedUpProjects(graceTime) {
const neverBackedUpCount = await projectsCollection.countDocuments({
'overleaf.backup.lastBackedUpVersion': null,
_id: { $lt: objectIdFromDate(graceTime) },
})
Metrics.gauge('backup_verification_never_backed_up', neverBackedUpCount)
}

View file

@ -0,0 +1,79 @@
// @ts-check
import { objectIdFromDate } from './utils.mjs'
import { db } from '../storage/lib/mongodb.js'
import config from 'config'
const projectsCollection = db.collection('projects')
const HAS_PROJECTS_WITHOUT_HISTORY =
config.get('hasProjectsWithoutHistory') === 'true'
/**
* @param {Date} start
* @param {Date} end
* @param {number} N
* @yields {string}
*/
export async function* getProjectsCreatedInDateRangeCursor(start, end, N) {
yield* getSampleProjectsCursor(N, [
{
$match: {
_id: {
$gt: objectIdFromDate(start),
$lte: objectIdFromDate(end),
},
},
},
])
}
export async function* getProjectsUpdatedInDateRangeCursor(start, end, N) {
yield* getSampleProjectsCursor(N, [
{
$match: {
'overleaf.history.updatedAt': {
$gt: start,
$lte: end,
},
},
},
])
}
/**
* @typedef {import('mongodb').Document} Document
*/
/**
*
* @generator
* @param {number} N
* @param {Array<Document>} preSampleAggregationStages
* @yields {string}
*/
export async function* getSampleProjectsCursor(
N,
preSampleAggregationStages = []
) {
const cursor = projectsCollection.aggregate([
...preSampleAggregationStages,
{ $sample: { size: N } },
{ $project: { 'overleaf.history.id': 1 } },
])
let validProjects = 0
let hasInvalidProject = false
for await (const project of cursor) {
if (HAS_PROJECTS_WITHOUT_HISTORY && !project.overleaf?.history?.id) {
hasInvalidProject = true
continue
}
validProjects++
yield project.overleaf.history.id.toString()
}
if (validProjects === 0 && hasInvalidProject) {
yield* getSampleProjectsCursor(N, preSampleAggregationStages)
}
}

View file

@ -0,0 +1,320 @@
// @ts-check
import { verifyProjectWithErrorContext } from '../storage/lib/backupVerifier.mjs'
import { promiseMapSettledWithLimit } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import {
getSampleProjectsCursor,
getProjectsCreatedInDateRangeCursor,
getProjectsUpdatedInDateRangeCursor,
} from './ProjectSampler.mjs'
import OError from '@overleaf/o-error'
import { setTimeout } from 'node:timers/promises'
const MS_PER_30_DAYS = 30 * 24 * 60 * 60 * 1000
const failureCounter = new metrics.prom.Counter({
name: 'backup_project_verification_failed',
help: 'Number of projects that failed verification',
labelNames: ['name'],
})
const successCounter = new metrics.prom.Counter({
name: 'backup_project_verification_succeeded',
help: 'Number of projects that succeeded verification',
})
let WRITE_METRICS = false
/**
* @typedef {import('node:events').EventEmitter} EventEmitter
*/
/**
* Allows writing metrics to be enabled or disabled.
* @param {Boolean} writeMetrics
*/
export function setWriteMetrics(writeMetrics) {
WRITE_METRICS = writeMetrics
}
/**
*
* @param {Error|unknown} error
* @param {string} historyId
*/
function handleVerificationError(error, historyId) {
const name = error instanceof Error ? error.name : 'UnknownError'
logger.error({ historyId, error, name }, 'error verifying project backup')
WRITE_METRICS && failureCounter.inc({ name })
return name
}
/**
*
* @param {Date} startDate
* @param {Date} endDate
* @param {number} interval
* @returns {Array<VerificationJobSpecification>}
*/
function splitJobs(startDate, endDate, interval) {
/** @type {Array<VerificationJobSpecification>} */
const jobs = []
while (startDate < endDate) {
const nextStart = new Date(
Math.min(startDate.getTime() + interval, endDate.getTime())
)
jobs.push({ startDate, endDate: nextStart })
startDate = nextStart
}
return jobs
}
/**
*
* @param {AsyncGenerator<string>} historyIdCursor
* @param {EventEmitter} [eventEmitter]
* @param {number} [delay] - Allows a delay between each verification
* @return {Promise<{verified: number, total: number, errorTypes: *[], hasFailure: boolean}>}
*/
async function verifyProjectsFromCursor(
historyIdCursor,
eventEmitter,
delay = 0
) {
const errorTypes = []
let verified = 0
let total = 0
let receivedShutdownSignal = false
if (eventEmitter) {
eventEmitter.once('shutdown', () => {
receivedShutdownSignal = true
})
}
for await (const historyId of historyIdCursor) {
if (receivedShutdownSignal) {
break
}
total++
try {
await verifyProjectWithErrorContext(historyId)
logger.debug({ historyId }, 'verified project backup successfully')
WRITE_METRICS && successCounter.inc()
verified++
} catch (error) {
const errorType = handleVerificationError(error, historyId)
errorTypes.push(errorType)
}
if (delay > 0) {
await setTimeout(delay)
}
}
return {
verified,
total,
errorTypes,
hasFailure: errorTypes.length > 0,
}
}
/**
*
* @param {number} nProjectsToSample
* @param {EventEmitter} [signal]
* @param {number} [delay]
* @return {Promise<VerificationJobStatus>}
*/
export async function verifyRandomProjectSample(
nProjectsToSample,
signal,
delay = 0
) {
const historyIds = await getSampleProjectsCursor(nProjectsToSample)
return await verifyProjectsFromCursor(historyIds, signal, delay)
}
/**
* Samples projects with history IDs between the specified dates and verifies them.
*
* @param {Date} startDate
* @param {Date} endDate
* @param {number} projectsPerRange
* @param {EventEmitter} [signal]
* @return {Promise<VerificationJobStatus>}
*/
async function verifyRange(startDate, endDate, projectsPerRange, signal) {
logger.info({ startDate, endDate }, 'verifying range')
const results = await verifyProjectsFromCursor(
getProjectsCreatedInDateRangeCursor(startDate, endDate, projectsPerRange),
signal
)
if (results.total === 0) {
logger.debug(
{ start: startDate, end: endDate },
'No projects found in range'
)
}
const jobStatus = {
...results,
startDate,
endDate,
}
logger.debug(
{ ...jobStatus, errorTypes: Array.from(new Set(jobStatus.errorTypes)) },
'Verified range'
)
return jobStatus
}
/**
* @typedef {Object} VerificationJobSpecification
* @property {Date} startDate
* @property {Date} endDate
*/
/**
* @typedef {import('./types.d.ts').VerificationJobStatus} VerificationJobStatus
*/
/**
* @typedef {Object} VerifyDateRangeOptions
* @property {Date} startDate
* @property {Date} endDate
* @property {number} [interval]
* @property {number} [projectsPerRange]
* @property {number} [concurrency]
* @property {EventEmitter} [signal]
*/
/**
*
* @param {VerifyDateRangeOptions} options
* @return {Promise<VerificationJobStatus>}
*/
export async function verifyProjectsCreatedInDateRange({
concurrency = 0,
projectsPerRange = 10,
startDate,
endDate,
interval = MS_PER_30_DAYS,
signal,
}) {
const jobs = splitJobs(startDate, endDate, interval)
if (jobs.length === 0) {
throw new OError('Time range could not be split into jobs', {
start: startDate,
end: endDate,
interval,
})
}
const settlements = await promiseMapSettledWithLimit(
concurrency,
jobs,
({ startDate, endDate }) =>
verifyRange(startDate, endDate, projectsPerRange, signal)
)
return settlements.reduce(
/**
*
* @param {VerificationJobStatus} acc
* @param settlement
* @return {VerificationJobStatus}
*/
(acc, settlement) => {
if (settlement.status !== 'rejected') {
if (settlement.value.hasFailure) {
acc.hasFailure = true
}
acc.total += settlement.value.total
acc.verified += settlement.value.verified
acc.errorTypes = acc.errorTypes.concat(settlement.value.errorTypes)
} else {
logger.error({ ...settlement.reason }, 'Error processing range')
}
return acc
},
/** @type {VerificationJobStatus} */
{
startDate,
endDate,
verified: 0,
total: 0,
hasFailure: false,
errorTypes: [],
}
)
}
/**
* Verifies that projects that have recently gone out of RPO have been updated.
*
* @param {Date} startDate
* @param {Date} endDate
* @param {number} nProjects
* @param {EventEmitter} [signal]
* @return {Promise<VerificationJobStatus>}
*/
export async function verifyProjectsUpdatedInDateRange(
startDate,
endDate,
nProjects,
signal
) {
logger.debug(
{ startDate, endDate, nProjects },
'Sampling projects updated in date range'
)
const results = await verifyProjectsFromCursor(
getProjectsUpdatedInDateRangeCursor(startDate, endDate, nProjects),
signal
)
if (results.total === 0) {
logger.debug(
{ start: startDate, end: endDate },
'No projects updated recently'
)
}
const jobStatus = {
...results,
startDate,
endDate,
}
logger.debug(
{ ...jobStatus, errorTypes: Array.from(new Set(jobStatus.errorTypes)) },
'Verified recently updated projects'
)
return jobStatus
}
/**
*
* @param {EventEmitter} signal
* @return {void}
*/
export function loopRandomProjects(signal) {
let shutdown = false
signal.on('shutdown', function () {
shutdown = true
})
async function loop() {
do {
try {
const result = await verifyRandomProjectSample(100, signal, 2_000)
logger.debug({ result }, 'verified random project sample')
} catch (error) {
logger.error({ error }, 'error verifying random project sample')
}
// eslint-disable-next-line no-unmodified-loop-condition
} while (!shutdown)
}
loop()
}

View file

@ -0,0 +1,32 @@
import config from 'config'
import { verifyProjectWithErrorContext } from '../storage/lib/backupVerifier.mjs'
import {
measureNeverBackedUpProjects,
measurePendingChangesBeforeTime,
} from './ProjectMetrics.mjs'
import { getEndDateForRPO, RPO } from './utils.mjs'
/** @type {Array<string>} */
const HEALTH_CHECK_PROJECTS = JSON.parse(config.get('healthCheckProjects'))
export async function healthCheck() {
if (!Array.isArray(HEALTH_CHECK_PROJECTS)) {
throw new Error('expected healthCheckProjects to be an array')
}
if (HEALTH_CHECK_PROJECTS.length !== 2) {
throw new Error('expected 2 healthCheckProjects')
}
if (!HEALTH_CHECK_PROJECTS.some(id => id.length === 24)) {
throw new Error('expected mongo id in healthCheckProjects')
}
if (!HEALTH_CHECK_PROJECTS.some(id => id.length < 24)) {
throw new Error('expected postgres id in healthCheckProjects')
}
for (const historyId of HEALTH_CHECK_PROJECTS) {
await verifyProjectWithErrorContext(historyId)
}
await measurePendingChangesBeforeTime(getEndDateForRPO(2))
await measureNeverBackedUpProjects(getEndDateForRPO(2))
}

View file

@ -0,0 +1,8 @@
export type VerificationJobStatus = {
verified: number
total: number
startDate?: Date
endDate?: Date
hasFailure: boolean
errorTypes: Array<string>
}

View file

@ -0,0 +1,35 @@
import { ObjectId } from 'mongodb'
import config from 'config'
export const RPO = parseInt(config.get('backupRPOInMS'), 10)
/**
* @param {Date} time
* @return {ObjectId}
*/
export function objectIdFromDate(time) {
return ObjectId.createFromTime(time.getTime() / 1000)
}
/**
* @param {number} [factor] - Multiply RPO by this factor, default is 1
* @return {Date}
*/
export function getEndDateForRPO(factor = 1) {
return new Date(Date.now() - RPO * factor)
}
/**
* Creates a startDate, endDate pair that checks a period of time before the RPO horizon
*
* @param {number} offset - How many seconds we should check
* @return {{endDate: Date, startDate: Date}}
*/
export function getDatesBeforeRPO(offset) {
const now = new Date()
const endDate = new Date(now.getTime() - RPO)
return {
endDate,
startDate: new Date(endDate.getTime() - offset * 1000),
}
}

View file

@ -7,4 +7,4 @@ history-v1
--node-version=20.18.2
--public-repo=False
--script-version=4.5.0
--tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,api/**/*,migrations/**/*,storage/**/*
--tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,backup-worker-app.mjs,api/**/*,migrations/**/*,storage/**/*

View file

@ -66,6 +66,7 @@
},
"healthCheckBlobs": "HEALTH_CHECK_BLOBS",
"healthCheckProjects": "HEALTH_CHECK_PROJECTS",
"backupRPOInMS": "BACKUP_RPO_IN_MS",
"minSoftDeletionPeriodDays": "MIN_SOFT_DELETION_PERIOD_DAYS",
"mongo": {
"uri": "MONGO_CONNECTION_STRING"

View file

@ -23,12 +23,14 @@
}
}
},
"backupRPOInMS": "3600000",
"chunkStore": {
"historyStoreConcurrency": "4"
},
"zipStore": {
"zipTimeoutMs": "360000"
},
"hasProjectsWithoutHistory": false,
"minSoftDeletionPeriodDays": "90",
"maxDeleteKeys": "1000",
"useDeleteObjects": "true",

View file

@ -36,6 +36,7 @@
},
"healthCheckBlobs": "[\"42/f70d7bba4ae1f07682e0358bd7a2068094fc023b\",\"000000000000000000000042/98d5521fe746bc2d11761edab5d0829bee286009\"]",
"healthCheckProjects": "[\"42\",\"000000000000000000000042\"]",
"backupRPOInMS": "360000",
"maxDeleteKeys": "3",
"useDeleteObjects": "false",
"mongo": {

View file

@ -40,7 +40,7 @@ services:
- ./test/acceptance/certs:/certs
depends_on:
mongo:
condition: service_healthy
condition: service_started
redis:
condition: service_healthy
postgres:
@ -74,10 +74,15 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
postgres:
image: postgres:10
environment:

View file

@ -57,7 +57,7 @@ services:
user: node
depends_on:
mongo:
condition: service_healthy
condition: service_started
redis:
condition: service_healthy
postgres:
@ -82,10 +82,15 @@ services:
mongo:
image: mongo:6.0.13
command: --replSet overleaf
healthcheck:
test: "mongosh --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'"
interval: 1s
retries: 20
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
postgres:
image: postgres:10

View file

@ -15,3 +15,6 @@ exports.zipStore = require('./lib/zip_store')
const { BlobStore, loadGlobalBlobs } = require('./lib/blob_store')
exports.BlobStore = BlobStore
exports.loadGlobalBlobs = loadGlobalBlobs
const { InvalidChangeError } = require('./lib/errors')
exports.InvalidChangeError = InvalidChangeError

View file

@ -1,14 +1,24 @@
// @ts-check
import config from 'config'
import OError from '@overleaf/o-error'
import { backupPersistor, projectBlobsBucket } from './backupPersistor.mjs'
import { Blob } from 'overleaf-editor-core'
import { BlobStore, makeProjectKey } from './blob_store/index.js'
import chunkStore from '../lib/chunk_store/index.js'
import {
backupPersistor,
chunksBucket,
projectBlobsBucket,
} from './backupPersistor.mjs'
import { Blob, Chunk, History } from 'overleaf-editor-core'
import { BlobStore, GLOBAL_BLOBS, makeProjectKey } from './blob_store/index.js'
import blobHash from './blob_hash.js'
import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
import logger from '@overleaf/logger'
import path from 'node:path'
import projectKey from './project_key.js'
import streams from './streams.js'
import objectPersistor from '@overleaf/object-persistor'
import { getEndDateForRPO } from '../../backupVerifier/utils.mjs'
/**
* @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
* @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor.js").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
*/
/**
@ -20,13 +30,13 @@ export async function verifyBlob(historyId, hash) {
}
/**
*
* @param {string} historyId
* @param {Array<string>} hashes
* @return {Promise<CachedPerProjectEncryptedS3Persistor>}
*/
export async function verifyBlobs(historyId, hashes) {
let projectCache
async function getProjectPersistor(historyId) {
try {
projectCache = await backupPersistor.forProjectRO(
return await backupPersistor.forProjectRO(
projectBlobsBucket,
makeProjectKey(historyId, '')
)
@ -36,16 +46,19 @@ export async function verifyBlobs(historyId, hashes) {
}
throw err
}
await verifyBlobsWithCache(historyId, projectCache, hashes)
}
/**
* @param {string} historyId
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
* @param {Array<string>} hashes
* @param {CachedPerProjectEncryptedS3Persistor} [projectCache]
*/
export async function verifyBlobsWithCache(historyId, projectCache, hashes) {
export async function verifyBlobs(historyId, hashes, projectCache) {
if (hashes.length === 0) throw new Error('bug: empty hashes')
if (!projectCache) {
projectCache = await getProjectPersistor(historyId)
}
const blobStore = new BlobStore(historyId)
for (const hash of hashes) {
const path = makeProjectKey(historyId, hash)
@ -58,41 +71,146 @@ export async function verifyBlobsWithCache(historyId, projectCache, hashes) {
})
} catch (err) {
if (err instanceof NotFoundError) {
throw new BackupCorruptedError('missing blob')
throw new BackupCorruptedMissingBlobError('missing blob', {
path,
hash,
})
}
throw err
}
const backupHash = await blobHash.fromStream(blob.getByteLength(), stream)
if (backupHash !== hash) {
throw new BackupCorruptedError('hash mismatch for backed up blob', {
path,
hash,
backupHash,
})
throw new BackupCorruptedInvalidBlobError(
'hash mismatch for backed up blob',
{
path,
hash,
backupHash,
}
)
}
}
}
export class BackupCorruptedError extends OError {}
export async function healthCheck() {
/** @type {Array<string>} */
const HEALTH_CHECK_BLOBS = JSON.parse(config.get('healthCheckBlobs'))
if (HEALTH_CHECK_BLOBS.length !== 2) {
throw new Error('expected 2 healthCheckBlobs')
}
if (!HEALTH_CHECK_BLOBS.some(path => path.split('/')[0].length === 24)) {
throw new Error('expected mongo id in healthCheckBlobs')
}
if (!HEALTH_CHECK_BLOBS.some(path => path.split('/')[0].length < 24)) {
throw new Error('expected postgres id in healthCheckBlobs')
}
if (HEALTH_CHECK_BLOBS.some(path => path.split('/')[1]?.length !== 40)) {
throw new Error('expected hash in healthCheckBlobs')
}
for (const path of HEALTH_CHECK_BLOBS) {
const [historyId, hash] = path.split('/')
await verifyBlob(historyId, hash)
/**
* @param {string} historyId
* @param {Date} [endTimestamp]
*/
export async function verifyProjectWithErrorContext(
historyId,
endTimestamp = getEndDateForRPO()
) {
try {
await verifyProject(historyId, endTimestamp)
} catch (err) {
// @ts-ignore err is Error instance
throw OError.tag(err, 'verifyProject', { historyId, endTimestamp })
}
}
/**
*
* @param {string} historyId
* @param {number} startVersion
* @param {CachedPerProjectEncryptedS3Persistor} backupPersistorForProject
* @return {Promise<any>}
*/
async function loadChunk(historyId, startVersion, backupPersistorForProject) {
const key = path.join(
projectKey.format(historyId),
projectKey.pad(startVersion)
)
try {
const buf = await streams.gunzipStreamToBuffer(
await backupPersistorForProject.getObjectStream(chunksBucket, key)
)
return JSON.parse(buf.toString('utf-8'))
} catch (err) {
if (err instanceof objectPersistor.Errors.NotFoundError) {
throw new Chunk.NotPersistedError(historyId)
}
if (err instanceof Error) {
throw OError.tag(err, 'Failed to load chunk', { historyId, startVersion })
}
throw err
}
}
/**
* @param {string} historyId
* @param {Date} endTimestamp
*/
export async function verifyProject(historyId, endTimestamp) {
const backend = chunkStore.getBackend(historyId)
const [first, last] = await Promise.all([
backend.getFirstChunkBeforeTimestamp(historyId, endTimestamp),
backend.getLastActiveChunkBeforeTimestamp(historyId, endTimestamp),
])
const chunksRecordsToVerify = [
{
chunkId: first.id,
chunkLabel: 'first',
},
]
if (first.startVersion !== last.startVersion) {
chunksRecordsToVerify.push({
chunkId: last.id,
chunkLabel: 'last before RPO',
})
}
const projectCache = await getProjectPersistor(historyId)
const chunks = await Promise.all(
chunksRecordsToVerify.map(async chunk => {
try {
return History.fromRaw(
await loadChunk(historyId, chunk.startVersion, projectCache)
)
} catch (err) {
if (err instanceof Chunk.NotPersistedError) {
throw new BackupRPOViolationChunkNotBackedUpError(
'BackupRPOviolation: chunk not backed up',
chunk
)
}
throw err
}
})
)
const seenBlobs = new Set()
const blobsToVerify = []
for (const chunk of chunks) {
/** @type {Set<string>} */
const chunkBlobs = new Set()
chunk.findBlobHashes(chunkBlobs)
let hasAddedBlobFromThisChunk = false
for (const blobHash of chunkBlobs) {
if (seenBlobs.has(blobHash)) continue // old blob
if (GLOBAL_BLOBS.has(blobHash)) continue // global blob
seenBlobs.add(blobHash)
if (!hasAddedBlobFromThisChunk) {
blobsToVerify.push(blobHash)
hasAddedBlobFromThisChunk = true
}
}
}
if (blobsToVerify.length === 0) {
logger.debug(
{
historyId,
chunksRecordsToVerify: chunksRecordsToVerify.map(c => c.chunkId),
},
'chunks contain no blobs to verify'
)
return
}
await verifyBlobs(historyId, blobsToVerify, projectCache)
}
export class BackupCorruptedError extends OError {}
export class BackupRPOViolationError extends OError {}
export class BackupCorruptedMissingBlobError extends BackupCorruptedError {}
export class BackupCorruptedInvalidBlobError extends BackupCorruptedError {}
export class BackupRPOViolationChunkNotBackedUpError extends OError {}

View file

@ -3,8 +3,18 @@ const { projects, backedUpBlobs } = require('../mongodb')
const OError = require('@overleaf/o-error')
// List projects with pending backups older than the specified interval
function listPendingBackups(timeIntervalMs = 0) {
function listPendingBackups(timeIntervalMs = 0, limit = null) {
const cutoffTime = new Date(Date.now() - timeIntervalMs)
const options = {
projection: { 'overleaf.backup.pendingChangeAt': 1 },
sort: { 'overleaf.backup.pendingChangeAt': 1 },
}
// Apply limit if provided
if (limit) {
options.limit = limit
}
const cursor = projects.find(
{
'overleaf.backup.pendingChangeAt': {
@ -12,10 +22,30 @@ function listPendingBackups(timeIntervalMs = 0) {
$lt: cutoffTime,
},
},
options
)
return cursor
}
// List projects that have never been backed up and are older than the specified interval
function listUninitializedBackups(timeIntervalMs = 0, limit = null) {
const cutoffTimeInSeconds = (Date.now() - timeIntervalMs) / 1000
const options = {
projection: { _id: 1 },
sort: { _id: 1 },
}
// Apply limit if provided
if (limit) {
options.limit = limit
}
const cursor = projects.find(
{
projection: { 'overleaf.backup': 1, 'overleaf.history': 1 },
sort: { 'overleaf.backup.pendingChangeAt': 1 },
}
'overleaf.backup.lastBackedUpVersion': null,
_id: {
$lt: ObjectId.createFromTime(cutoffTimeInSeconds),
},
},
options
)
return cursor
}
@ -176,6 +206,7 @@ module.exports = {
updateCurrentMetadataIfNotSet,
updatePendingChangeTimestamp,
listPendingBackups,
listUninitializedBackups,
getBackedUpBlobHashes,
unsetBackedUpBlobHashes,
}

View file

@ -155,15 +155,22 @@ async function loadAtTimestamp(projectId, timestamp) {
*
* @param {string} projectId
* @param {Chunk} chunk
* @param {Date} [earliestChangeTimestamp]
* @return {Promise.<number>} for the chunkId of the inserted chunk
*/
async function create(projectId, chunk) {
async function create(projectId, chunk, earliestChangeTimestamp) {
assert.projectId(projectId, 'bad projectId')
assert.instance(chunk, Chunk, 'bad chunk')
assert.maybe.date(earliestChangeTimestamp, 'bad timestamp')
const backend = getBackend(projectId)
const chunkId = await uploadChunk(projectId, chunk)
await backend.confirmCreate(projectId, chunk, chunkId)
await backend.confirmCreate(
projectId,
chunk,
chunkId,
earliestChangeTimestamp
)
}
/**
@ -195,18 +202,31 @@ async function uploadChunk(projectId, chunk) {
* @param {string} projectId
* @param {number} oldEndVersion
* @param {Chunk} newChunk
* @param {Date} [earliestChangeTimestamp]
* @return {Promise}
*/
async function update(projectId, oldEndVersion, newChunk) {
async function update(
projectId,
oldEndVersion,
newChunk,
earliestChangeTimestamp
) {
assert.projectId(projectId, 'bad projectId')
assert.integer(oldEndVersion, 'bad oldEndVersion')
assert.instance(newChunk, Chunk, 'bad newChunk')
assert.maybe.date(earliestChangeTimestamp, 'bad timestamp')
const backend = getBackend(projectId)
const oldChunkId = await getChunkIdForVersion(projectId, oldEndVersion)
const newChunkId = await uploadChunk(projectId, newChunk)
await backend.confirmUpdate(projectId, oldChunkId, newChunk, newChunkId)
await backend.confirmUpdate(
projectId,
oldChunkId,
newChunk,
newChunkId,
earliestChangeTimestamp
)
}
/**

View file

@ -54,6 +54,35 @@ async function getChunkForVersion(projectId, version) {
return chunkFromRecord(record)
}
/**
* Get the metadata for the chunk that contains the given version before the endTime.
*/
async function getFirstChunkBeforeTimestamp(projectId, timestamp) {
assert.mongoId(projectId, 'bad projectId')
assert.date(timestamp, 'bad timestamp')
const recordActive = await getChunkForVersion(projectId, 0)
if (recordActive && recordActive.endTimestamp <= timestamp) {
return recordActive
}
// fallback to deleted chunk
const recordDeleted = await mongodb.chunks.findOne(
{
projectId: new ObjectId(projectId),
state: 'deleted',
startVersion: 0,
updatedAt: { $lte: timestamp }, // indexed for state=deleted
endTimestamp: { $lte: timestamp },
},
{ sort: { updatedAt: -1 } }
)
if (recordDeleted) {
return chunkFromRecord(recordDeleted)
}
throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
}
/**
* Get the metadata for the chunk that contains the version that was current at
* the given timestamp.
@ -86,6 +115,39 @@ async function getChunkForTimestamp(projectId, timestamp) {
return chunkFromRecord(record)
}
/**
* Get the metadata for the chunk that contains the version that was current before
* the given timestamp.
*/
async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
assert.mongoId(projectId, 'bad projectId')
assert.date(timestamp, 'bad timestamp')
const record = await mongodb.chunks.findOne(
{
projectId: new ObjectId(projectId),
state: 'active',
$or: [
{
endTimestamp: {
$lte: timestamp,
},
},
{
endTimestamp: null,
},
],
},
// We use the index on the startVersion for sorting records. This assumes
// that timestamps go up with each version.
{ sort: { startVersion: -1 } }
)
if (record == null) {
throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
}
return chunkFromRecord(record)
}
/**
* Get all of a project's chunk ids
*/
@ -137,7 +199,13 @@ async function insertPendingChunk(projectId, chunk) {
/**
* Record that a new chunk was created.
*/
async function confirmCreate(projectId, chunk, chunkId, mongoOpts = {}) {
async function confirmCreate(
projectId,
chunk,
chunkId,
earliestChangeTimestamp,
mongoOpts = {}
) {
assert.mongoId(projectId, 'bad projectId')
assert.instance(chunk, Chunk, 'bad chunk')
assert.mongoId(chunkId, 'bad chunkId')
@ -166,13 +234,23 @@ async function confirmCreate(projectId, chunk, chunkId, mongoOpts = {}) {
if (result.matchedCount === 0) {
throw new OError('pending chunk not found', { projectId, chunkId })
}
await updateProjectRecord(projectId, chunk, mongoOpts)
await updateProjectRecord(
projectId,
chunk,
earliestChangeTimestamp,
mongoOpts
)
}
/**
* Write the metadata to the project record
*/
async function updateProjectRecord(projectId, chunk, mongoOpts = {}) {
async function updateProjectRecord(
projectId,
chunk,
earliestChangeTimestamp,
mongoOpts = {}
) {
// record the end version against the project
await mongodb.projects.updateOne(
{
@ -189,7 +267,7 @@ async function updateProjectRecord(projectId, chunk, mongoOpts = {}) {
// be cleared every time a backup is completed.
$min: {
'overleaf.backup.pendingChangeAt':
chunk.getEndTimestamp() || new Date(),
earliestChangeTimestamp || chunk.getEndTimestamp() || new Date(),
},
},
mongoOpts
@ -199,7 +277,13 @@ async function updateProjectRecord(projectId, chunk, mongoOpts = {}) {
/**
* Record that a chunk was replaced by a new one.
*/
async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) {
async function confirmUpdate(
projectId,
oldChunkId,
newChunk,
newChunkId,
earliestChangeTimestamp
) {
assert.mongoId(projectId, 'bad projectId')
assert.mongoId(oldChunkId, 'bad oldChunkId')
assert.instance(newChunk, Chunk, 'bad newChunk')
@ -209,7 +293,13 @@ async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) {
try {
await session.withTransaction(async () => {
await deleteChunk(projectId, oldChunkId, { session })
await confirmCreate(projectId, newChunk, newChunkId, { session })
await confirmCreate(
projectId,
newChunk,
newChunkId,
earliestChangeTimestamp,
{ session }
)
})
} finally {
await session.endSession()
@ -310,6 +400,8 @@ function chunkFromRecord(record) {
module.exports = {
getLatestChunk,
getFirstChunkBeforeTimestamp,
getLastActiveChunkBeforeTimestamp,
getChunkForVersion,
getChunkForTimestamp,
getProjectChunkIds,

View file

@ -46,6 +46,59 @@ async function getChunkForVersion(projectId, version) {
return chunkFromRecord(record)
}
/**
* Get the metadata for the chunk that contains the given version.
*/
async function getFirstChunkBeforeTimestamp(projectId, timestamp) {
assert.date(timestamp, 'bad timestamp')
const recordActive = await getChunkForVersion(projectId, 0)
// projectId must be valid if getChunkForVersion did not throw
projectId = parseInt(projectId, 10)
if (recordActive && recordActive.endTimestamp <= timestamp) {
return recordActive
}
// fallback to deleted chunk
const recordDeleted = await knex('old_chunks')
.where('doc_id', projectId)
.where('start_version', '=', 0)
.where('end_timestamp', '<=', timestamp)
.orderBy('end_version', 'desc')
.first()
if (recordDeleted) {
return chunkFromRecord(recordDeleted)
}
throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
}
/**
* Get the metadata for the chunk that contains the version that was current at
* the given timestamp.
*/
async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
assert.date(timestamp, 'bad timestamp')
assert.postgresId(projectId, 'bad projectId')
projectId = parseInt(projectId, 10)
const query = knex('chunks')
.where('doc_id', projectId)
.where(function () {
this.where('end_timestamp', '<=', timestamp).orWhere(
'end_timestamp',
null
)
})
.orderBy('end_version', 'desc', 'last')
const record = await query.first()
if (!record) {
throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
}
return chunkFromRecord(record)
}
/**
* Get the metadata for the chunk that contains the version that was current at
* the given timestamp.
@ -140,7 +193,12 @@ async function insertPendingChunk(projectId, chunk) {
/**
* Record that a new chunk was created.
*/
async function confirmCreate(projectId, chunk, chunkId) {
async function confirmCreate(
projectId,
chunk,
chunkId,
earliestChangeTimestamp
) {
assert.postgresId(projectId, `bad projectId ${projectId}`)
projectId = parseInt(projectId, 10)
@ -149,14 +207,20 @@ async function confirmCreate(projectId, chunk, chunkId) {
_deletePendingChunk(tx, projectId, chunkId),
_insertChunk(tx, projectId, chunk, chunkId),
])
await updateProjectRecord(projectId, chunk)
await updateProjectRecord(projectId, chunk, earliestChangeTimestamp)
})
}
/**
* Record that a chunk was replaced by a new one.
*/
async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) {
async function confirmUpdate(
projectId,
oldChunkId,
newChunk,
newChunkId,
earliestChangeTimestamp
) {
assert.postgresId(projectId, `bad projectId ${projectId}`)
projectId = parseInt(projectId, 10)
@ -166,7 +230,7 @@ async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) {
_deletePendingChunk(tx, projectId, newChunkId),
_insertChunk(tx, projectId, newChunk, newChunkId),
])
await updateProjectRecord(projectId, newChunk)
await updateProjectRecord(projectId, newChunk, earliestChangeTimestamp)
})
}
@ -280,6 +344,8 @@ async function generateProjectId() {
module.exports = {
getLatestChunk,
getFirstChunkBeforeTimestamp,
getLastActiveChunkBeforeTimestamp,
getChunkForVersion,
getChunkForTimestamp,
getProjectChunkIds,

View file

@ -65,6 +65,9 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) {
const blobStore = new BlobStore(projectId)
const earliestChangeTimestamp =
allChanges.length > 0 ? allChanges[0].getTimestamp() : null
let currentChunk
/**
@ -78,12 +81,6 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) {
let originalEndVersion
let changesToPersist
/**
* It's only useful to log validation errors once per flush. When we enforce
* content hash validation, it will stop the flush right away anyway.
*/
let validationErrorLogged = false
limits = limits || {}
_.defaults(limits, {
changeBucketMinutes: 60,
@ -128,22 +125,7 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) {
for (const operation of change.iterativelyApplyTo(currentSnapshot, {
strict: true,
})) {
try {
await validateContentHash(operation)
} catch (err) {
// Temporary: skip validation errors
if (err instanceof InvalidChangeError) {
if (!validationErrorLogged) {
logger.warn(
{ err, projectId },
'content snapshot mismatch (ignored)'
)
validationErrorLogged = true
}
} else {
throw err
}
}
await validateContentHash(operation)
}
chunk.pushChanges([change])
@ -220,7 +202,12 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) {
checkElapsedTime(timer)
await chunkStore.update(projectId, originalEndVersion, currentChunk)
await chunkStore.update(
projectId,
originalEndVersion,
currentChunk,
earliestChangeTimestamp
)
}
async function createNewChunksAsNeeded() {
@ -234,7 +221,7 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) {
if (changesPushed) {
checkElapsedTime(timer)
currentChunk = chunk
await chunkStore.create(projectId, chunk)
await chunkStore.create(projectId, chunk, earliestChangeTimestamp)
} else {
throw new Error('failed to fill empty chunk')
}

View file

@ -2,8 +2,12 @@
import logger from '@overleaf/logger'
import commandLineArgs from 'command-line-args'
import { History } from 'overleaf-editor-core'
import { getProjectChunks, loadLatestRaw } from '../lib/chunk_store/index.js'
import { Chunk, History, Snapshot } from 'overleaf-editor-core'
import {
getProjectChunks,
loadLatestRaw,
create,
} from '../lib/chunk_store/index.js'
import { client } from '../lib/mongodb.js'
import knex from '../lib/knex.js'
import { historyStore } from '../lib/history_store.js'
@ -30,7 +34,7 @@ import {
projectBlobsBucket,
} from '../lib/backupPersistor.mjs'
import { backupGenerator } from '../lib/backupGenerator.mjs'
import { promises as fs } from 'node:fs'
import { promises as fs, createWriteStream } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import projectKey from '../lib/project_key.js'
@ -89,7 +93,7 @@ process.on('SIGTERM', handleSignal)
function handleSignal() {
gracefulShutdownInitiated = true
console.warn('graceful shutdown initiated, draining queue')
logger.info({}, 'graceful shutdown initiated, draining queue')
}
async function retry(fn, times, delayMs) {
@ -321,12 +325,18 @@ const optionDefinitions = [
description: 'Time interval in seconds for pending backups (default: 3600)',
defaultValue: 3600,
},
{
name: 'fix',
type: Number,
description: 'Fix projects without chunks',
},
{
name: 'init',
alias: 'I',
type: Boolean,
description: 'Initialize backups for all projects.',
},
{ name: 'output', alias: 'o', type: String, description: 'Output file' },
{
name: 'start-date',
type: String,
@ -366,6 +376,7 @@ function handleOptions() {
!options.list &&
!options.pending &&
!options.init &&
!(options.fix >= 0) &&
!(options.compare && options['start-date'] && options['end-date'])
if (projectIdRequired && !options.projectId) {
@ -680,19 +691,68 @@ function convertToISODate(dateStr) {
return new Date(dateStr + 'T00:00:00.000Z').toISOString()
}
export async function fixProjectsWithoutChunks(options) {
const limit = options.fix || 1
const query = {
'overleaf.history.id': { $exists: true },
'overleaf.backup.lastBackedUpVersion': { $in: [null] },
}
const cursor = client
.db()
.collection('projects')
.find(query, {
projection: { _id: 1, 'overleaf.history.id': 1 },
readPreference: READ_PREFERENCE_SECONDARY,
})
.limit(limit)
for await (const project of cursor) {
const historyId = project.overleaf.history.id.toString()
const chunks = await getProjectChunks(historyId)
if (chunks.length > 0) {
continue
}
if (DRY_RUN) {
console.log(
'Would create new chunk for Project ID:',
project._id.toHexString(),
'History ID:',
historyId,
'Chunks:',
chunks
)
} else {
console.log(
'Creating new chunk for Project ID:',
project._id.toHexString(),
'History ID:',
historyId,
'Chunks:',
chunks
)
const snapshot = new Snapshot()
const history = new History(snapshot, [])
const chunk = new Chunk(history, 0)
await create(historyId, chunk)
const newChunks = await getProjectChunks(historyId)
console.log('New chunk:', newChunks)
}
}
}
export async function initializeProjects(options) {
await ensureGlobalBlobsLoaded()
let totalErrors = 0
let totalProjects = 0
const query = {
'overleaf.history.id': { $exists: true },
'overleaf.backup.lastBackedUpVersion': { $exists: false },
'overleaf.backup.pendingChangeAt': { $exists: false },
_id: {
'overleaf.backup.lastBackedUpVersion': { $in: [null] },
}
if (options['start-date'] && options['end-date']) {
query._id = {
$gte: objectIdFromInput(convertToISODate(options['start-date'])),
$lt: objectIdFromInput(convertToISODate(options['end-date'])),
},
}
}
const cursor = client
@ -703,6 +763,18 @@ export async function initializeProjects(options) {
readPreference: READ_PREFERENCE_SECONDARY,
})
if (options.output) {
console.log("Writing project IDs to file: '" + options.output + "'")
const output = createWriteStream(options.output)
for await (const project of cursor) {
output.write(project._id.toHexString() + '\n')
totalProjects++
}
output.end()
console.log('Wrote ' + totalProjects + ' project IDs to file')
return
}
for await (const project of cursor) {
if (gracefulShutdownInitiated) {
console.warn('graceful shutdown: stopping project initialization')
@ -969,11 +1041,12 @@ async function main() {
const options = handleOptions()
await ensureGlobalBlobsLoaded()
const projectId = options.projectId
if (options.status) {
await displayBackupStatus(projectId)
} else if (options.list) {
await displayPendingBackups(options)
} else if (options.fix !== undefined) {
await fixProjectsWithoutChunks(options)
} else if (options.pending) {
await backupPendingProjects(options)
} else if (options.init) {

View file

@ -0,0 +1,171 @@
// @ts-check
import commandLineArgs from 'command-line-args'
import { backupBlob, downloadBlobToDir } from '../lib/backupBlob.mjs'
import withTmpDir from '../../api/controllers/with_tmp_dir.js'
import {
BlobStore,
GLOBAL_BLOBS,
loadGlobalBlobs,
} from '../lib/blob_store/index.js'
import assert from '../lib/assert.js'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
import { setTimeout } from 'node:timers/promises'
import fs from 'node:fs'
await loadGlobalBlobs()
/**
* Gracefully shutdown the process
* @return {Promise<void>}
*/
async function gracefulShutdown() {
console.log('Gracefully shutting down')
await knex.destroy()
await client.close()
await setTimeout(100)
process.exit()
}
/**
*
* @param {string} row
* @return {BackupBlobJob}
*/
function parseCSVRow(row) {
const [historyId, hash] = row.split(',')
validateBackedUpBlobJob({ historyId, hash })
return { historyId, hash }
}
/**
*
* @param {BackupBlobJob} job
*/
function validateBackedUpBlobJob(job) {
assert.projectId(job.historyId)
assert.blobHash(job.hash)
}
/**
*
* @param {string} path
* @return {Promise<Array<BackupBlobJob>>}
*/
async function readCSV(path) {
let fh
/** @type {Array<BackupBlobJob>} */
const rows = []
try {
fh = await fs.promises.open(path, 'r')
} catch (error) {
console.error(`Could not open file: ${error}`)
throw error
}
for await (const line of fh.readLines()) {
try {
const row = parseCSVRow(line)
if (GLOBAL_BLOBS.has(row.hash)) {
console.log(`Skipping global blob: ${line}`)
continue
}
rows.push(row)
} catch (error) {
console.error(error instanceof Error ? error.message : error)
console.log(`Skipping invalid row: ${line}`)
}
}
return rows
}
/**
* @typedef {Object} BackupBlobJob
* @property {string} hash
* @property {string} historyId
*/
/**
* @param {Object} options
* @property {string} [options.historyId]
* @property {string} [options.hash]
* @property {string} [options.input]
* @return {Promise<Array<BackupBlobJob>>}
*/
async function initialiseJobs({ historyId, hash, input }) {
if (input) {
return await readCSV(input)
}
if (!historyId) {
console.error('historyId is required')
process.exitCode = 1
await gracefulShutdown()
}
if (!hash) {
console.error('hash is required')
process.exitCode = 1
await gracefulShutdown()
}
validateBackedUpBlobJob({ historyId, hash })
if (GLOBAL_BLOBS.has(hash)) {
console.error(`Blob ${hash} is a global blob; not backing up`)
process.exitCode = 1
await gracefulShutdown()
}
return [{ hash, historyId }]
}
/**
*
* @param {string} historyId
* @param {string} hash
* @return {Promise<void>}
*/
export async function downloadAndBackupBlob(historyId, hash) {
const blobStore = new BlobStore(historyId)
const blob = await blobStore.getBlob(hash)
if (!blob) {
throw new Error(`Blob ${hash} could not be loaded`)
}
await withTmpDir(`blob-${hash}`, async tmpDir => {
const filePath = await downloadBlobToDir(historyId, blob, tmpDir)
console.log(`Downloaded blob ${hash} to ${filePath}`)
await backupBlob(historyId, blob, filePath)
console.log('Backed up blob')
})
}
let jobs
const options = commandLineArgs([
{ name: 'historyId', type: String },
{ name: 'hash', type: String },
{ name: 'input', type: String },
])
try {
jobs = await initialiseJobs(options)
} catch (error) {
console.error(error)
await gracefulShutdown()
}
if (!Array.isArray(jobs)) {
// This is mostly to satisfy typescript
process.exitCode = 1
await gracefulShutdown()
process.exit(1)
}
for (const { historyId, hash } of jobs) {
try {
await downloadAndBackupBlob(historyId, hash)
} catch (error) {
console.error(error)
process.exitCode = 1
}
}
await gracefulShutdown()

View file

@ -32,34 +32,18 @@ async function takeSample(sampleSize) {
[
{ $sample: { size: sampleSize } },
{
$project: {
_id: 0,
hasBackup: {
$ifNull: ['$overleaf.backup.lastBackedUpVersion', false],
},
},
$match: { 'overleaf.backup.lastBackedUpVersion': { $exists: true } },
},
{
$group: {
_id: null,
totalSampled: { $sum: 1 },
backedUp: {
$sum: {
$cond: ['$hasBackup', 1, 0],
},
},
},
$count: 'total',
},
],
{ readPreference: READ_PREFERENCE_SECONDARY }
)
.toArray()
if (results.length === 0) {
return { totalSampled: 0, backedUp: 0 }
}
return results[0]
const count = results[0]?.total || 0
return { totalSampled: sampleSize, backedUp: count }
}
function calculateStatistics(
@ -67,7 +51,7 @@ function calculateStatistics(
cumulativeBackedUp,
totalPopulation
) {
const proportion = cumulativeBackedUp / cumulativeSampled
const proportion = Math.max(1, cumulativeBackedUp) / cumulativeSampled
// Standard error with finite population correction
const fpc = Math.sqrt(

View file

@ -2,6 +2,11 @@ import Queue from 'bull'
import config from 'config'
import commandLineArgs from 'command-line-args'
import logger from '@overleaf/logger'
import {
listPendingBackups,
listUninitializedBackups,
getBackupStatus,
} from '../lib/backup_store/index.js'
logger.initialize('backup-queue')
@ -28,16 +33,100 @@ const optionDefinitions = [
description: 'Project IDs or date range in YYYY-MM-DD:YYYY-MM-DD format',
},
{ name: 'monitor', type: Boolean },
{
name: 'queue-pending',
type: Number,
description:
'Find projects with pending changes older than N seconds and add them to the queue',
},
{
name: 'show-pending',
type: Number,
description:
'Show count of pending projects older than N seconds without adding to queue',
},
{
name: 'limit',
type: Number,
description: 'Limit the number of jobs to be added',
},
{
name: 'interval',
type: Number,
description: 'Time in seconds to spread jobs over (default: 300)',
defaultValue: 300,
},
{
name: 'backoff-delay',
type: Number,
description:
'Backoff delay in milliseconds for failed jobs (default: 1000)',
defaultValue: 1000,
},
{
name: 'attempts',
type: Number,
description: 'Number of retry attempts for failed jobs (default: 3)',
defaultValue: 3,
},
{
name: 'warn-threshold',
type: Number,
description: 'Warn about any project exceeding this pending age',
defaultValue: 2 * 3600, // 2 hours
},
{
name: 'verbose',
alias: 'v',
type: Boolean,
description: 'Show detailed information when used with --show-pending',
},
]
// Parse command line arguments
const options = commandLineArgs(optionDefinitions)
const WARN_THRESHOLD = options['warn-threshold']
// Helper to validate date format
function isValidDateFormat(dateStr) {
return /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
}
// Helper to validate the pending time parameter
function validatePendingTime(option, value) {
if (typeof value !== 'number' || value <= 0) {
console.error(
`Error: --${option} requires a positive numeric TIME argument in seconds`
)
console.error(`Example: --${option} 3600`)
process.exit(1)
}
return value
}
// Helper to format the pending time display
function formatPendingTime(timestamp) {
const now = new Date()
const diffMs = now - timestamp
const seconds = Math.floor(diffMs / 1000)
return `${timestamp.toISOString()} (${seconds} seconds ago)`
}
// Helper to add a job to the queue, checking for duplicates
async function addJobWithCheck(queue, data, options) {
const jobId = options.jobId
// Check if the job already exists
const existingJob = await queue.getJob(jobId)
if (existingJob) {
return { job: existingJob, added: false }
} else {
const job = await queue.add(data, options)
return { job, added: true }
}
}
// Setup queue event listeners
function setupMonitoring() {
console.log('Starting queue monitoring. Press Ctrl+C to exit.')
@ -99,15 +188,125 @@ async function addDateRangeJob(input) {
)
return
}
const job = await backupQueue.add(
const jobId = `backup-${startDate}-to-${endDate}`
const { job, added } = await addJobWithCheck(
backupQueue,
{ startDate, endDate },
{ jobId: `backup-${startDate}-to-${endDate}` }
{ jobId }
)
console.log(
`Added date range backup job: ${startDate} to ${endDate}, job ID: ${job.id}`
`${added ? 'Added' : 'Already exists'}: date range backup job: ${startDate} to ${endDate}, job ID: ${job.id}`
)
}
// Helper to list pending and uninitialized backups
// This function combines the two cursors into a single generator
// to yield projects from both lists
async function* pendingCursor(timeIntervalMs, limit) {
for await (const project of listPendingBackups(timeIntervalMs, limit)) {
yield project
}
for await (const project of listUninitializedBackups(timeIntervalMs, limit)) {
yield project
}
}
// Process pending projects with changes older than the specified seconds
async function processPendingProjects(
age,
showOnly,
limit,
verbose,
jobInterval,
jobOpts = {}
) {
const timeIntervalMs = age * 1000
console.log(
`Finding projects with pending changes older than ${age} seconds${showOnly ? ' (count only)' : ''}`
)
let count = 0
let addedCount = 0
let existingCount = 0
// Pass the limit directly to MongoDB query for better performance
const changeTimes = []
for await (const project of pendingCursor(timeIntervalMs, limit)) {
const projectId = project._id.toHexString()
const pendingAt =
project.overleaf?.backup?.pendingChangeAt || project._id.getTimestamp()
if (pendingAt) {
changeTimes.push(pendingAt)
const pendingAge = Math.floor((Date.now() - pendingAt.getTime()) / 1000)
if (pendingAge > WARN_THRESHOLD) {
const backupStatus = await getBackupStatus(projectId)
logger.warn(
{
projectId,
pendingAt,
pendingAge,
backupStatus,
warnThreshold: WARN_THRESHOLD,
},
`pending change exceeds rpo warning threshold`
)
}
}
if (showOnly && verbose) {
console.log(
`Project: ${projectId} (pending since: ${formatPendingTime(pendingAt)})`
)
} else if (!showOnly) {
const delay = Math.floor(Math.random() * jobInterval * 1000) // add random delay to avoid all jobs running simultaneously
const { job, added } = await addJobWithCheck(
backupQueue,
{ projectId, pendingChangeAt: pendingAt.getTime() },
{ ...jobOpts, delay, jobId: projectId }
)
if (added) {
if (verbose) {
console.log(
`Added job for project: ${projectId}, job ID: ${job.id} (pending since: ${formatPendingTime(pendingAt)})`
)
}
addedCount++
} else {
if (verbose) {
console.log(
`Job already exists for project: ${projectId}, job ID: ${job.id} (pending since: ${formatPendingTime(pendingAt)})`
)
}
existingCount++
}
}
count++
if (count % 1000 === 0) {
console.log(
`Processed ${count} projects`,
showOnly ? '' : `(${addedCount} added, ${existingCount} existing)`
)
}
}
const oldestChange = changeTimes.reduce((min, time) =>
time < min ? time : min
)
if (showOnly) {
console.log(
`Found ${count} projects with pending changes (not added to queue)`
)
} else {
console.log(`Found ${count} projects with pending changes:`)
console.log(` ${addedCount} jobs added to queue`)
console.log(` ${existingCount} jobs already existed in queue`)
console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`)
}
}
// Main execution block
async function run() {
const optionCount = [
@ -115,6 +314,8 @@ async function run() {
options.status,
options.add,
options.monitor,
options['queue-pending'] !== undefined,
options['show-pending'] !== undefined,
].filter(Boolean).length
if (optionCount > 1) {
console.error('Only one option can be specified')
@ -141,24 +342,65 @@ async function run() {
await addDateRangeJob(input)
} else {
// Handle project ID format
const job = await backupQueue.add(
const { job, added } = await addJobWithCheck(
backupQueue,
{ projectId: input },
{ jobId: input }
)
console.log(`Added job for project: ${input}, job ID: ${job.id}`)
console.log(
`${added ? 'Added' : 'Already exists'}: job for project: ${input}, job ID: ${job.id}`
)
}
}
} else if (options.monitor) {
setupMonitoring()
} else if (options['queue-pending'] !== undefined) {
const age = validatePendingTime('queue-pending', options['queue-pending'])
await processPendingProjects(
age,
false,
options.limit,
options.verbose,
options.interval,
{
attempts: options.attempts,
backoff: {
type: 'exponential',
delay: options['backoff-delay'],
},
}
)
} else if (options['show-pending'] !== undefined) {
const age = validatePendingTime('show-pending', options['show-pending'])
await processPendingProjects(age, true, options.limit, options.verbose)
} else {
console.log('Usage:')
console.log(' --clean Clean up completed and failed jobs')
console.log(' --status Show current job counts')
console.log(' --add [projectId] Add a job for the specified projectId')
console.log(' --clean Clean up completed and failed jobs')
console.log(' --status Show current job counts')
console.log(' --add [projectId] Add a job for the specified projectId')
console.log(
' --add [YYYY-MM-DD:YYYY-MM-DD] Add a job for the specified date range'
)
console.log(' --monitor Monitor queue events')
console.log(' --monitor Monitor queue events')
console.log(
' --queue-pending TIME Find projects with changes older than TIME seconds and add them to the queue'
)
console.log(
' --show-pending TIME Show count of pending projects older than TIME seconds'
)
console.log(' --limit N Limit the number of jobs to be added')
console.log(
' --interval TIME Time interval in seconds to spread jobs over'
)
console.log(
' --backoff-delay TIME Backoff delay in milliseconds for failed jobs (default: 1000)'
)
console.log(
' --attempts N Number of retry attempts for failed jobs (default: 3)'
)
console.log(
' --verbose, -v Show detailed information when used with --show-pending'
)
}
}

View file

@ -9,8 +9,12 @@ import {
} from './backup.mjs'
const CONCURRENCY = 15
const WARN_THRESHOLD = 2 * 60 * 60 * 1000 // warn if projects are older than this
const redisOptions = config.get('redis.queue')
const TIME_BUCKETS = [10, 100, 500, 1000, 5000, 10000, 30000, 60000]
const JOB_TIME_BUCKETS = [10, 100, 500, 1000, 5000, 10000, 30000, 60000] // milliseconds
const LAG_TIME_BUCKETS_HRS = [
0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 3, 4, 5, 6,
] // hours
// Configure backup settings to match worker concurrency
configureBackup({ concurrency: 50, useSecondary: true })
@ -27,12 +31,12 @@ const backupQueue = new Queue('backup', {
// Log queue events
backupQueue.on('active', job => {
logger.info({ job }, 'job is now active')
logger.debug({ job }, 'job is now active')
})
backupQueue.on('completed', (job, result) => {
metrics.inc('backup_worker_job', 1, { status: 'completed' })
logger.info({ job, result }, 'job completed')
logger.debug({ job, result }, 'job completed')
})
backupQueue.on('failed', (job, err) => {
@ -41,7 +45,7 @@ backupQueue.on('failed', (job, err) => {
})
backupQueue.on('waiting', jobId => {
logger.info({ jobId }, 'job is waiting')
logger.debug({ jobId }, 'job is waiting')
})
backupQueue.on('error', error => {
@ -69,7 +73,7 @@ backupQueue.process(CONCURRENCY, async job => {
const { projectId, startDate, endDate } = job.data
if (projectId) {
return await runBackup(projectId)
return await runBackup(projectId, job.data, job)
} else if (startDate && endDate) {
return await runInit(startDate, endDate)
} else {
@ -77,23 +81,40 @@ backupQueue.process(CONCURRENCY, async job => {
}
})
async function runBackup(projectId) {
async function runBackup(projectId, data, job) {
const { pendingChangeAt } = data
// record the time it takes to run the backup job
const timer = new metrics.Timer(
'backup_worker_job_duration',
1,
{},
TIME_BUCKETS
JOB_TIME_BUCKETS
)
const pendingAge = Date.now() - pendingChangeAt
if (pendingAge > WARN_THRESHOLD) {
logger.warn(
{ projectId, pendingAge, job },
'project has been pending for a long time'
)
}
try {
logger.info({ projectId }, 'processing backup for project')
const { errors, completed } = await backupProject(projectId, {})
metrics.inc('backup_worker_project', completed - errors, {
logger.debug({ projectId }, 'processing backup for project')
await backupProject(projectId, {})
metrics.inc('backup_worker_project', 1, {
status: 'success',
})
metrics.inc('backup_worker_project', errors, { status: 'failed' })
timer.done()
return `backup completed ${projectId} (${errors} failed in ${completed} projects)`
// record the replication lag (time from change to backup)
if (pendingChangeAt) {
metrics.histogram(
'backup_worker_replication_lag_in_hours',
(Date.now() - pendingChangeAt) / (3600 * 1000),
LAG_TIME_BUCKETS_HRS
)
}
return `backup completed ${projectId}`
} catch (err) {
metrics.inc('backup_worker_project', 1, { status: 'failed' })
logger.error({ projectId, err }, 'backup failed')
throw err // Re-throw to mark job as failed
}

View file

@ -0,0 +1,177 @@
import fs from 'node:fs'
import { makeProjectKey } from '../lib/blob_store/index.js'
import { backupPersistor, projectBlobsBucket } from '../lib/backupPersistor.mjs'
import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
import commandLineArgs from 'command-line-args'
import OError from '@overleaf/o-error'
import assert from '../lib/assert.js'
import { client, projects } from '../lib/mongodb.js'
import { ObjectId } from 'mongodb'
import { setTimeout } from 'node:timers/promises'
const { input, verbose } = commandLineArgs([
{ name: 'input', type: String },
{ name: 'verbose', type: Boolean, defaultValue: false },
])
function parseCSVRow(row) {
const [path] = row.split(',')
const pathSegments = path.split('/')
const historyId = `${pathSegments[0]}${pathSegments[1]}${pathSegments[2]}`
.split('')
.reverse()
.join('')
return { historyId, path, hash: `${pathSegments[3]}${pathSegments[4]}` }
}
async function* readCSV(path) {
let fh
try {
fh = await fs.promises.open(path, 'r')
} catch (error) {
console.error(`Could not open file: ${error}`)
throw error
}
for await (const line of fh.readLines()) {
try {
const row = parseCSVRow(line)
yield row
} catch (error) {
console.error(error instanceof Error ? error.message : error)
console.log(`Skipping invalid row: ${line}`)
}
}
}
class MissingDEKError extends OError {}
class InvalidHistoryIdError extends OError {}
class MissingProjectError extends OError {}
class MissingBlobError extends OError {}
async function getProjectPersistor(historyId) {
try {
return await backupPersistor.forProjectRO(
projectBlobsBucket,
makeProjectKey(historyId, '')
)
} catch (err) {
if (err instanceof NotFoundError) {
throw new MissingDEKError('dek does not exist', { historyId }, err)
}
throw err
}
}
async function checkBlobExists(path, historyId) {
const persistor = await getProjectPersistor(historyId)
return await persistor.getObjectSize(projectBlobsBucket, path)
}
let total = 0
const errors = {
invalidProjectId: 0,
notBackedUpProjectId: 0,
missingBlob: 0,
notInMongo: 0,
unknown: 0,
}
const notInMongoProjectIds = new Set()
const notBackedUpProjectIds = new Set()
let stopping = false
process.on('SIGTERM', () => {
console.log('SIGTERM received')
stopping = true
})
process.on('SIGINT', () => {
console.log('SIGINT received')
stopping = true
})
/**
*
* @param {string} historyId
* @param {string} path
* @param {string} hash
* @return {Promise<void>}
*/
async function checkPath(historyId, path, hash) {
try {
assert.mongoId(historyId)
} catch (error) {
throw InvalidHistoryIdError('invalid history id', { historyId })
}
if (notInMongoProjectIds.has(historyId)) {
throw new MissingProjectError('project not in mongo', { historyId })
}
if (notBackedUpProjectIds.has(historyId)) {
throw new MissingDEKError('project not backed up', { historyId })
}
const project = await projects.findOne({ _id: new ObjectId(historyId) })
if (!project) {
notInMongoProjectIds.add(historyId)
throw new MissingProjectError('project not in mongo', { historyId })
}
try {
await checkBlobExists(path, historyId)
} catch (error) {
if (error instanceof NotFoundError) {
throw new MissingBlobError('missing blob', { historyId, hash })
}
if (error instanceof MissingDEKError) {
notBackedUpProjectIds.add(historyId)
}
throw error
}
}
for await (const line of readCSV(input)) {
if (stopping) break
total++
if (total % 10_000 === 0) {
console.log(`checked ${total}`)
}
const { historyId, path, hash } = line
try {
await checkPath(historyId, path, hash)
if (verbose) {
console.log(`✓ Project ${historyId} has ${hash} backed up`)
}
} catch (error) {
if (error instanceof InvalidHistoryIdError) {
errors.invalidProjectId++
console.warn(`invalid historyId ${historyId}`)
continue
} else if (error instanceof MissingProjectError) {
errors.notInMongo++
console.warn(`✗ project ${historyId} not in mongo`)
continue
} else if (error instanceof MissingDEKError) {
errors.notBackedUpProjectId++
console.error(`✗ Project DEK ${historyId} not found`)
continue
} else if (error instanceof MissingBlobError) {
errors.missingBlob++
console.error(`✗ missing blob ${hash} from project ${historyId}`)
continue
}
errors.unknown++
console.error(error)
}
}
console.log(`total checked: ${total}`)
console.log(`invalid project id: ${errors.invalidProjectId}`)
console.log(`not found in mongo: ${errors.notInMongo}`)
console.log(`missing blob: ${errors.missingBlob}`)
console.log(`project not backed up: ${errors.notBackedUpProjectId}`)
console.log(`unknown errors: ${errors.unknown}`)
await client.close()
await setTimeout(100)
process.exit()

View file

@ -0,0 +1,33 @@
import commandLineArgs from 'command-line-args'
import { verifyProjectWithErrorContext } from '../lib/backupVerifier.mjs'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
import { setTimeout } from 'node:timers/promises'
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
const { historyId } = commandLineArgs([{ name: 'historyId', type: String }])
async function gracefulShutdown(code = process.exitCode) {
await knex.destroy()
await client.close()
await setTimeout(1_000)
process.exit(code)
}
if (!historyId) {
console.error('missing --historyId')
process.exitCode = 1
await gracefulShutdown()
}
await loadGlobalBlobs()
try {
await verifyProjectWithErrorContext(historyId)
console.log('OK')
} catch (error) {
console.error('error verifying', error)
process.exitCode = 1
} finally {
await gracefulShutdown()
}

View file

@ -0,0 +1,215 @@
// @ts-check
import commandLineArgs from 'command-line-args'
import {
setWriteMetrics,
verifyProjectsCreatedInDateRange,
verifyRandomProjectSample,
verifyProjectsUpdatedInDateRange,
} from '../../backupVerifier/ProjectVerifier.mjs'
import knex from '../lib/knex.js'
import { client } from '../lib/mongodb.js'
import { setTimeout } from 'node:timers/promises'
import logger from '@overleaf/logger'
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
import { getDatesBeforeRPO } from '../../backupVerifier/utils.mjs'
import { EventEmitter } from 'node:events'
import { mongodb } from '../index.js'
logger.logger.level('fatal')
const usageMessage = [
'Usage: node verify_sampled_projects.mjs [--startDate <start>] [--endDate <end>] [--nProjects <n>] [--verbose] [--usage] [--writeMetrics] [--concurrency <n>] [--strategy <range|random>]',
'strategy: defaults to "range"; startDate and endDate are required for "range" strategy',
].join('\n')
/**
* Gracefully shutdown the process
* @param code
* @return {Promise<void>}
*/
async function gracefulShutdown(code = process.exitCode) {
await knex.destroy()
await client.close()
await setTimeout(1_000)
process.exit(code)
}
const STATS = {
verifiable: 0,
unverifiable: 0,
}
/**
* @typedef {Object} CLIOptions
* @property {(signal: EventEmitter) => Promise<VerificationJobStatus>} projectVerifier
* @property {boolean} verbose
*/
/**
* @typedef {import('../../backupVerifier/types.d.ts').VerificationJobStatus} VerificationJobStatus
*/
/**
*
* @return {CLIOptions}
*/
function getOptions() {
const {
startDate,
endDate,
concurrency,
writeMetrics,
verbose,
nProjects,
strategy,
usage,
} = commandLineArgs([
{ name: 'startDate', type: String },
{ name: 'endDate', type: String },
{ name: 'concurrency', type: Number, defaultValue: 1 },
{ name: 'verbose', type: Boolean, defaultValue: false },
{ name: 'nProjects', type: Number, defaultValue: 10 },
{ name: 'usage', type: Boolean, defaultValue: false },
{ name: 'writeMetrics', type: Boolean, defaultValue: false },
{ name: 'strategy', type: String, defaultValue: 'range' },
])
if (usage) {
console.log(usageMessage)
process.exit(0)
}
if (!['range', 'random', 'recent'].includes(strategy)) {
throw new Error(`Invalid strategy: ${strategy}`)
}
setWriteMetrics(writeMetrics)
switch (strategy) {
case 'random':
console.log('Verifying random projects')
return {
verbose,
projectVerifier: signal => verifyRandomProjectSample(nProjects, signal),
}
case 'recent':
return {
verbose,
projectVerifier: async signal => {
const { startDate, endDate } = getDatesBeforeRPO(3 * 3600)
return await verifyProjectsUpdatedInDateRange(
startDate,
endDate,
nProjects,
signal
)
},
}
case 'range':
default: {
if (!startDate || !endDate) {
throw new Error(usageMessage)
}
const start = Date.parse(startDate)
const end = Date.parse(endDate)
if (Number.isNaN(start)) {
throw new Error(`Invalid start date: ${startDate}`)
}
if (Number.isNaN(end)) {
throw new Error(`Invalid end date: ${endDate}`)
}
if (verbose) {
console.log(`Verifying from ${startDate} to ${endDate}`)
console.log(`Concurrency: ${concurrency}`)
}
STATS.ranges = 0
return {
projectVerifier: signal =>
verifyProjectsCreatedInDateRange({
startDate: new Date(start),
endDate: new Date(end),
projectsPerRange: nProjects,
concurrency,
signal,
}),
verbose,
}
}
}
}
/**
* @type {CLIOptions}
*/
let options
try {
options = getOptions()
} catch (error) {
console.error(error)
process.exitCode = 1
await gracefulShutdown(1)
process.exit() // just here so the type checker knows that the process will exit
}
const { projectVerifier, verbose } = options
if (verbose) {
logger.logger.level('debug')
}
/**
*
* @param {Array<string>} array
* @param {string} matchString
* @return {*}
*/
function sumStringInstances(array, matchString) {
return array.reduce((total, string) => {
return string === matchString ? total + 1 : total
}, 0)
}
/**
*
* @param {VerificationJobStatus} stats
*/
function displayStats(stats) {
console.log(`Verified projects: ${stats.verified}`)
console.log(`Total projects sampled: ${stats.total}`)
if (stats.errorTypes.length > 0) {
console.log('Errors:')
for (const error of new Set(stats.errorTypes)) {
console.log(`${error}: ${sumStringInstances(stats.errorTypes, error)}`)
}
}
}
const shutdownEmitter = new EventEmitter()
shutdownEmitter.on('shutdown', async () => {
await gracefulShutdown()
})
process.on('SIGTERM', () => {
shutdownEmitter.emit('shutdown')
})
process.on('SIGINT', () => {
shutdownEmitter.emit('shutdown')
})
await loadGlobalBlobs()
try {
const stats = await projectVerifier(shutdownEmitter)
displayStats(stats)
console.log(`completed`)
} catch (error) {
console.error(error)
console.log('completed with errors')
process.exitCode = 1
} finally {
console.log('shutting down')
await gracefulShutdown()
}

Some files were not shown because too many files have changed in this diff Show more