From ee85d948e2a43105c11f71f3c16650fd49d63e4c Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 13 Jan 2023 12:42:29 +0000 Subject: [PATCH] Avoid duplicating a math-closing dollar sign (#11227) GitOrigin-RevId: ef2ef77e26df59d1af3df6dc664e284d3c70102d --- libraries/overleaf-editor-core/.dockerignore | 1 + libraries/overleaf-editor-core/.gitignore | 5 + libraries/overleaf-editor-core/.nvmrc | 1 + .../overleaf-editor-core/buildscript.txt | 9 + libraries/overleaf-editor-core/index.js | 26 + libraries/overleaf-editor-core/lib/author.js | 70 + .../overleaf-editor-core/lib/author_list.js | 45 + libraries/overleaf-editor-core/lib/blob.js | 100 + libraries/overleaf-editor-core/lib/change.js | 333 + .../overleaf-editor-core/lib/change_note.js | 60 + .../lib/change_request.js | 90 + libraries/overleaf-editor-core/lib/chunk.js | 166 + .../lib/chunk_response.js | 32 + libraries/overleaf-editor-core/lib/file.js | 241 + .../lib/file_data/binary_file_data.js | 71 + .../lib/file_data/hash_file_data.js | 63 + .../lib/file_data/hollow_binary_file_data.js | 46 + .../lib/file_data/hollow_string_file_data.js | 55 + .../lib/file_data/index.js | 169 + .../lib/file_data/lazy_string_file_data.js | 137 + .../lib/file_data/string_file_data.js | 80 + .../overleaf-editor-core/lib/file_map.js | 317 + libraries/overleaf-editor-core/lib/history.js | 125 + libraries/overleaf-editor-core/lib/label.js | 82 + .../lib/operation/add_file_operation.js | 81 + .../lib/operation/edit_file_operation.js | 93 + .../lib/operation/index.js | 463 + .../lib/operation/move_file_operation.js | 54 + .../lib/operation/no_operation.js | 21 + .../operation/set_file_metadata_operation.js | 55 + .../lib/operation/text_operation.js | 682 + .../overleaf-editor-core/lib/origin/index.js | 54 + .../lib/origin/restore_origin.js | 64 + .../overleaf-editor-core/lib/ot_client.js | 237 + .../overleaf-editor-core/lib/safe_pathname.js | 91 + .../overleaf-editor-core/lib/snapshot.js | 240 + libraries/overleaf-editor-core/lib/types.ts | 13 + libraries/overleaf-editor-core/lib/util.js | 13 + .../lib/v2_doc_versions.js | 55 + libraries/overleaf-editor-core/package.json | 31 + .../overleaf-editor-core/test/change.test.js | 37 + .../test/edit_file_operation.test.js | 80 + .../overleaf-editor-core/test/file.test.js | 94 + .../test/file_map.test.js | 202 + .../overleaf-editor-core/test/history.test.js | 42 + .../test/hollow_string_file_data.test.js | 22 + .../overleaf-editor-core/test/label.test.js | 17 + .../test/lazy_string_file_data.test.js | 98 + .../test/move_file_operation.test.js | 42 + .../test/operation.test.js | 746 + .../test/safe_pathname.test.js | 113 + .../test/snapshot.test.js | 92 + .../test/string_file_data.test.js | 37 + .../test/support/fake_blob_store.js | 35 + .../test/support/random.js | 38 + .../test/text_operation.test.js | 269 + libraries/overleaf-editor-core/tsconfig.json | 14 + services/history-v1/.gitignore | 3 + services/history-v1/.mocharc.json | 3 + services/history-v1/.nvmrc | 1 + services/history-v1/Dockerfile | 26 + services/history-v1/Makefile | 103 + services/history-v1/README.md | 51 + services/history-v1/api/app/security.js | 147 + .../history-v1/api/controllers/expressify.js | 10 + .../api/controllers/health_checks.js | 23 + .../api/controllers/project_import.js | 140 + .../history-v1/api/controllers/projects.js | 235 + services/history-v1/api/controllers/render.js | 17 + .../api/controllers/stream_size_limit.js | 26 + .../api/controllers/with_tmp_dir.js | 27 + services/history-v1/api/swagger/index.js | 256 + .../history-v1/api/swagger/project_import.js | 108 + services/history-v1/api/swagger/projects.js | 429 + .../api/swagger/security_definitions.js | 17 + services/history-v1/app.js | 169 + services/history-v1/benchmarks/blob_store.js | 82 + services/history-v1/benchmarks/index.js | 17 + services/history-v1/buildscript.txt | 8 + services/history-v1/cloud-formation.json | 572 + .../config/custom-environment-variables.json | 60 + services/history-v1/config/default.json | 29 + services/history-v1/config/development.json | 41 + services/history-v1/config/production.json | 1 + services/history-v1/config/test.json | 40 + services/history-v1/docker-compose.ci.yml | 74 + services/history-v1/docker-compose.yml | 77 + services/history-v1/knexfile.js | 19 + .../migrations/20220228163642_initial.js | 80 + .../20221026201437_chunk_start_version.js | 23 + .../20221027201324_unique_start_version.js | 41 + ...0221118213808_delete_global_blobs_table.js | 7 + services/history-v1/nodemon.json | 20 + services/history-v1/package.json | 68 + services/history-v1/storage/index.js | 17 + services/history-v1/storage/lib/assert.js | 52 + .../storage/lib/batch_blob_store.js | 40 + services/history-v1/storage/lib/blob_hash.js | 78 + .../storage/lib/blob_store/index.js | 290 + .../storage/lib/blob_store/mongo.js | 289 + .../storage/lib/blob_store/postgres.js | 113 + .../storage/lib/chunk_store/errors.js | 7 + .../storage/lib/chunk_store/index.js | 331 + .../storage/lib/chunk_store/mongo.js | 248 + .../storage/lib/chunk_store/postgres.js | 269 + .../storage/lib/hash_check_blob_store.js | 30 + .../history-v1/storage/lib/history_store.js | 132 + services/history-v1/storage/lib/knex.js | 6 + services/history-v1/storage/lib/metrics.js | 3 + services/history-v1/storage/lib/mongodb.js | 12 + .../history-v1/storage/lib/persist_changes.js | 171 + services/history-v1/storage/lib/persistor.js | 26 + .../history-v1/storage/lib/project_archive.js | 118 + .../history-v1/storage/lib/project_key.js | 23 + services/history-v1/storage/lib/streams.js | 100 + services/history-v1/storage/lib/temp.js | 25 + services/history-v1/storage/lib/zip_store.js | 134 + .../01-create-blob-hashes-table.sql | 14320 ++++++++++++++++ .../02-set-global-flag.sql | 3 + .../03-create-global-blobs-table.sql | 16 + .../04-swap-global-blob-tables.sql | 22 + .../scripts/global-blobs-db-cleanup/README.md | 9 + .../global-blobs-db-cleanup/rollback.sql | 22 + .../storage/tasks/backfill_start_version.js | 109 + .../storage/tasks/compress_changes.js | 107 + .../storage/tasks/copy_project_blobs.js | 294 + .../storage/tasks/count_blob_references.js | 246 + .../storage/tasks/delete_old_chunks.js | 36 + .../storage/tasks/fix_duplicate_versions.js | 156 + services/history-v1/storage/tasks/index.js | 1 + .../test/acceptance/js/api/auth.test.js | 195 + .../test/acceptance/js/api/end_to_end.test.js | 391 + .../acceptance/js/api/project_blobs.test.js | 123 + .../acceptance/js/api/project_import.test.js | 57 + .../acceptance/js/api/project_updates.test.js | 850 + .../test/acceptance/js/api/projects.test.js | 201 + .../js/api/support/expect_response.js | 53 + .../js/api/support/test_projects.js | 17 + .../acceptance/js/api/support/test_server.js | 133 + .../js/storage/batch_blob_store.test.js | 52 + .../acceptance/js/storage/blob_hash.test.js | 15 + .../acceptance/js/storage/blob_store.test.js | 442 + .../js/storage/blob_store_mongo.test.js | 126 + .../acceptance/js/storage/chunk_store.test.js | 338 + .../acceptance/js/storage/files/empty.tex | 0 .../acceptance/js/storage/files/graph.png | Bin 0 -> 13476 bytes .../acceptance/js/storage/files/hello.txt | 1 + .../acceptance/js/storage/files/non_bmp.txt | 1 + .../js/storage/files/null_characters.txt | Bin 0 -> 3 bytes .../acceptance/js/storage/fixtures/chunks.js | 21 + .../acceptance/js/storage/fixtures/docs.js | 9 + .../acceptance/js/storage/fixtures/index.js | 7 + .../js/storage/persist_changes.test.js | 186 + .../js/storage/project_archive.test.js | 204 + .../acceptance/js/storage/project_key.test.js | 21 + .../acceptance/js/storage/support/cleanup.js | 64 + .../acceptance/js/storage/support/fetch.js | 6 + .../acceptance/js/storage/support/fixtures.js | 20 + .../js/storage/support/test_files.js | 27 + .../acceptance/js/storage/support/unzip.js | 22 + .../test/acceptance/js/storage/tasks.test.js | 111 + .../acceptance/js/storage/zip_store.test.js | 56 + services/history-v1/test/setup.js | 58 + services/project-history/.eslintignore | 1 + .../project-history/.github/ISSUE_TEMPLATE.md | 38 + .../.github/PULL_REQUEST_TEMPLATE.md | 46 + services/project-history/.gitignore | 8 + services/project-history/.mocharc.json | 3 + services/project-history/.nvmrc | 1 + services/project-history/Dockerfile | 26 + services/project-history/Makefile | 100 + services/project-history/README.md | 71 + services/project-history/app.js | 24 + .../project-history/app/js/BlobManager.js | 113 + .../project-history/app/js/ChunkTranslator.js | 389 + .../project-history/app/js/DiffGenerator.js | 273 + .../project-history/app/js/DiffManager.js | 229 + .../app/js/DocumentUpdaterManager.js | 81 + .../project-history/app/js/ErrorRecorder.js | 308 + services/project-history/app/js/Errors.js | 10 + .../app/js/FileTreeDiffGenerator.js | 143 + .../project-history/app/js/FlushManager.js | 159 + .../project-history/app/js/HashManager.js | 63 + .../project-history/app/js/HealthChecker.js | 82 + .../app/js/HistoryApiManager.js | 23 + .../app/js/HistoryStoreManager.js | 447 + .../project-history/app/js/HttpController.js | 464 + .../project-history/app/js/LabelsManager.js | 170 + .../app/js/LargeFileManager.js | 88 + .../project-history/app/js/LocalFileWriter.js | 127 + .../project-history/app/js/LockManager.js | 273 + .../app/js/OperationsCompressor.js | 20 + services/project-history/app/js/Profiler.js | 80 + .../project-history/app/js/RedisManager.js | 455 + .../project-history/app/js/RetryManager.js | 194 + services/project-history/app/js/Router.js | 201 + .../project-history/app/js/SnapshotManager.js | 146 + .../app/js/SummarizedUpdatesManager.js | 320 + .../project-history/app/js/SyncManager.js | 745 + .../app/js/UpdateCompressor.js | 302 + .../app/js/UpdateTranslator.js | 201 + .../app/js/UpdatesProcessor.js | 629 + services/project-history/app/js/Validation.js | 12 + services/project-history/app/js/Versions.js | 68 + .../project-history/app/js/WebApiManager.js | 96 + services/project-history/app/js/mongodb.js | 15 + services/project-history/app/js/server.js | 65 + services/project-history/buildscript.txt | 8 + .../config/settings.defaults.cjs | 99 + .../project-history/docker-compose.ci.yml | 58 + services/project-history/docker-compose.yml | 61 + services/project-history/nodemon.json | 18 + services/project-history/package.json | 59 + .../scripts/add_index_for_sync_state.js | 21 + .../scripts/clear_dangling_timestamps.js | 44 + .../project-history/scripts/clear_deleted.js | 136 + .../scripts/clear_deleted_history.js | 175 + .../scripts/clear_filestore_404.js | 204 + .../clear_project_version_out_of_order.js | 260 + .../scripts/debug_translate_updates.js | 76 + services/project-history/scripts/flush_all.js | 93 + .../project-history/scripts/force_resync.js | 241 + .../35c9bd86574d61dcadbce2fdd3d4a0684272c6ea | 404 + .../4f785a4c192155b240e3042b3a7388b47603f423 | 3 + .../c6654ea913979e13e22022653d284444f284a172 | 5 + .../e13c315d53aaef3aa34550a86b09cff091ace220 | 7 + .../f28571f561d198b87c24cc6a98b78e87b665e22d | 404 + .../test/acceptance/fixtures/chunks/0-3.json | 74 + .../test/acceptance/fixtures/chunks/4-6.json | 74 + .../test/acceptance/fixtures/chunks/7-8.json | 63 + .../test/acceptance/js/DeleteProjectTests.js | 82 + .../test/acceptance/js/DiffTests.js | 414 + .../acceptance/js/DiscardingUpdatesTests.js | 72 + .../test/acceptance/js/FileTreeDiffTests.js | 856 + .../test/acceptance/js/FlushManagerTests.js | 244 + .../test/acceptance/js/HealthCheckTests.js | 76 + .../test/acceptance/js/LabelsTests.js | 253 + .../acceptance/js/ReadingASnapshotTests.js | 297 + .../test/acceptance/js/RetryTests.js | 193 + .../test/acceptance/js/SendingUpdatesTests.js | 1983 +++ .../acceptance/js/SummarisedUpdatesTests.js | 249 + .../test/acceptance/js/SyncTests.js | 556 + .../test/acceptance/js/helpers/HistoryId.js | 7 + .../js/helpers/HistoryStoreClient.js | 42 + .../js/helpers/ProjectHistoryApp.js | 43 + .../js/helpers/ProjectHistoryClient.js | 300 + services/project-history/test/setup.js | 6 + .../unit/js/BlobManager/BlobManagerTests.js | 156 + .../ChunkTranslator/ChunkTranslatorTests.js | 1734 ++ .../js/DiffGenerator/DiffGeneratorTests.js | 390 + .../unit/js/DiffManager/DiffManagerTests.js | 523 + .../DocumentUpdaterManagerTests.js | 184 + .../js/ErrorRecorder/ErrorRecorderTest.js | 123 + .../HistoryStoreManagerTests.js | 554 + .../js/HttpController/HttpControllerTests.js | 541 + .../js/LabelsManager/LabelsManagerTests.js | 258 + .../unit/js/LockManager/LockManagerTests.js | 423 + .../OperationsCompressorTests.js | 76 + .../unit/js/RedisManager/RedisManagerTests.js | 818 + .../unit/js/RetryManager/RetryManagerTests.js | 144 + .../SnapshotManager/SnapshotManagerTests.js | 501 + .../SummarizedUpdatesManagerTests.js | 845 + .../unit/js/SyncManager/SyncManagerTests.js | 1073 ++ .../UpdateCompressor/UpdateCompressorTests.js | 942 + .../UpdateTranslator/UpdateTranslatorTests.js | 838 + .../UpdatesManager/UpdatesProcessorTests.js | 496 + .../test/unit/js/Versions/VersionTest.js | 170 + .../js/WebApiManager/WebApiManagerTests.js | 163 + 268 files changed, 57782 insertions(+) create mode 100644 libraries/overleaf-editor-core/.dockerignore create mode 100644 libraries/overleaf-editor-core/.gitignore create mode 100644 libraries/overleaf-editor-core/.nvmrc create mode 100644 libraries/overleaf-editor-core/buildscript.txt create mode 100644 libraries/overleaf-editor-core/index.js create mode 100644 libraries/overleaf-editor-core/lib/author.js create mode 100644 libraries/overleaf-editor-core/lib/author_list.js create mode 100644 libraries/overleaf-editor-core/lib/blob.js create mode 100644 libraries/overleaf-editor-core/lib/change.js create mode 100644 libraries/overleaf-editor-core/lib/change_note.js create mode 100644 libraries/overleaf-editor-core/lib/change_request.js create mode 100644 libraries/overleaf-editor-core/lib/chunk.js create mode 100644 libraries/overleaf-editor-core/lib/chunk_response.js create mode 100644 libraries/overleaf-editor-core/lib/file.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/binary_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/hash_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/index.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_data/string_file_data.js create mode 100644 libraries/overleaf-editor-core/lib/file_map.js create mode 100644 libraries/overleaf-editor-core/lib/history.js create mode 100644 libraries/overleaf-editor-core/lib/label.js create mode 100644 libraries/overleaf-editor-core/lib/operation/add_file_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/edit_file_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/index.js create mode 100644 libraries/overleaf-editor-core/lib/operation/move_file_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/no_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/text_operation.js create mode 100644 libraries/overleaf-editor-core/lib/origin/index.js create mode 100644 libraries/overleaf-editor-core/lib/origin/restore_origin.js create mode 100644 libraries/overleaf-editor-core/lib/ot_client.js create mode 100644 libraries/overleaf-editor-core/lib/safe_pathname.js create mode 100644 libraries/overleaf-editor-core/lib/snapshot.js create mode 100644 libraries/overleaf-editor-core/lib/types.ts create mode 100644 libraries/overleaf-editor-core/lib/util.js create mode 100644 libraries/overleaf-editor-core/lib/v2_doc_versions.js create mode 100644 libraries/overleaf-editor-core/package.json create mode 100644 libraries/overleaf-editor-core/test/change.test.js create mode 100644 libraries/overleaf-editor-core/test/edit_file_operation.test.js create mode 100644 libraries/overleaf-editor-core/test/file.test.js create mode 100644 libraries/overleaf-editor-core/test/file_map.test.js create mode 100644 libraries/overleaf-editor-core/test/history.test.js create mode 100644 libraries/overleaf-editor-core/test/hollow_string_file_data.test.js create mode 100644 libraries/overleaf-editor-core/test/label.test.js create mode 100644 libraries/overleaf-editor-core/test/lazy_string_file_data.test.js create mode 100644 libraries/overleaf-editor-core/test/move_file_operation.test.js create mode 100644 libraries/overleaf-editor-core/test/operation.test.js create mode 100644 libraries/overleaf-editor-core/test/safe_pathname.test.js create mode 100644 libraries/overleaf-editor-core/test/snapshot.test.js create mode 100644 libraries/overleaf-editor-core/test/string_file_data.test.js create mode 100644 libraries/overleaf-editor-core/test/support/fake_blob_store.js create mode 100644 libraries/overleaf-editor-core/test/support/random.js create mode 100644 libraries/overleaf-editor-core/test/text_operation.test.js create mode 100644 libraries/overleaf-editor-core/tsconfig.json create mode 100644 services/history-v1/.gitignore create mode 100644 services/history-v1/.mocharc.json create mode 100644 services/history-v1/.nvmrc create mode 100644 services/history-v1/Dockerfile create mode 100644 services/history-v1/Makefile create mode 100644 services/history-v1/README.md create mode 100644 services/history-v1/api/app/security.js create mode 100644 services/history-v1/api/controllers/expressify.js create mode 100644 services/history-v1/api/controllers/health_checks.js create mode 100644 services/history-v1/api/controllers/project_import.js create mode 100644 services/history-v1/api/controllers/projects.js create mode 100644 services/history-v1/api/controllers/render.js create mode 100644 services/history-v1/api/controllers/stream_size_limit.js create mode 100644 services/history-v1/api/controllers/with_tmp_dir.js create mode 100644 services/history-v1/api/swagger/index.js create mode 100644 services/history-v1/api/swagger/project_import.js create mode 100644 services/history-v1/api/swagger/projects.js create mode 100644 services/history-v1/api/swagger/security_definitions.js create mode 100644 services/history-v1/app.js create mode 100644 services/history-v1/benchmarks/blob_store.js create mode 100644 services/history-v1/benchmarks/index.js create mode 100644 services/history-v1/buildscript.txt create mode 100644 services/history-v1/cloud-formation.json create mode 100644 services/history-v1/config/custom-environment-variables.json create mode 100644 services/history-v1/config/default.json create mode 100644 services/history-v1/config/development.json create mode 100644 services/history-v1/config/production.json create mode 100644 services/history-v1/config/test.json create mode 100644 services/history-v1/docker-compose.ci.yml create mode 100644 services/history-v1/docker-compose.yml create mode 100644 services/history-v1/knexfile.js create mode 100644 services/history-v1/migrations/20220228163642_initial.js create mode 100644 services/history-v1/migrations/20221026201437_chunk_start_version.js create mode 100644 services/history-v1/migrations/20221027201324_unique_start_version.js create mode 100644 services/history-v1/migrations/20221118213808_delete_global_blobs_table.js create mode 100644 services/history-v1/nodemon.json create mode 100644 services/history-v1/package.json create mode 100644 services/history-v1/storage/index.js create mode 100644 services/history-v1/storage/lib/assert.js create mode 100644 services/history-v1/storage/lib/batch_blob_store.js create mode 100644 services/history-v1/storage/lib/blob_hash.js create mode 100644 services/history-v1/storage/lib/blob_store/index.js create mode 100644 services/history-v1/storage/lib/blob_store/mongo.js create mode 100644 services/history-v1/storage/lib/blob_store/postgres.js create mode 100644 services/history-v1/storage/lib/chunk_store/errors.js create mode 100644 services/history-v1/storage/lib/chunk_store/index.js create mode 100644 services/history-v1/storage/lib/chunk_store/mongo.js create mode 100644 services/history-v1/storage/lib/chunk_store/postgres.js create mode 100644 services/history-v1/storage/lib/hash_check_blob_store.js create mode 100644 services/history-v1/storage/lib/history_store.js create mode 100644 services/history-v1/storage/lib/knex.js create mode 100644 services/history-v1/storage/lib/metrics.js create mode 100644 services/history-v1/storage/lib/mongodb.js create mode 100644 services/history-v1/storage/lib/persist_changes.js create mode 100644 services/history-v1/storage/lib/persistor.js create mode 100644 services/history-v1/storage/lib/project_archive.js create mode 100644 services/history-v1/storage/lib/project_key.js create mode 100644 services/history-v1/storage/lib/streams.js create mode 100644 services/history-v1/storage/lib/temp.js create mode 100644 services/history-v1/storage/lib/zip_store.js create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/01-create-blob-hashes-table.sql create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/02-set-global-flag.sql create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/03-create-global-blobs-table.sql create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/04-swap-global-blob-tables.sql create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/README.md create mode 100644 services/history-v1/storage/scripts/global-blobs-db-cleanup/rollback.sql create mode 100644 services/history-v1/storage/tasks/backfill_start_version.js create mode 100644 services/history-v1/storage/tasks/compress_changes.js create mode 100755 services/history-v1/storage/tasks/copy_project_blobs.js create mode 100755 services/history-v1/storage/tasks/count_blob_references.js create mode 100644 services/history-v1/storage/tasks/delete_old_chunks.js create mode 100755 services/history-v1/storage/tasks/fix_duplicate_versions.js create mode 100644 services/history-v1/storage/tasks/index.js create mode 100644 services/history-v1/test/acceptance/js/api/auth.test.js create mode 100644 services/history-v1/test/acceptance/js/api/end_to_end.test.js create mode 100644 services/history-v1/test/acceptance/js/api/project_blobs.test.js create mode 100644 services/history-v1/test/acceptance/js/api/project_import.test.js create mode 100644 services/history-v1/test/acceptance/js/api/project_updates.test.js create mode 100644 services/history-v1/test/acceptance/js/api/projects.test.js create mode 100644 services/history-v1/test/acceptance/js/api/support/expect_response.js create mode 100644 services/history-v1/test/acceptance/js/api/support/test_projects.js create mode 100644 services/history-v1/test/acceptance/js/api/support/test_server.js create mode 100644 services/history-v1/test/acceptance/js/storage/batch_blob_store.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/blob_hash.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/blob_store.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/blob_store_mongo.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/chunk_store.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/files/empty.tex create mode 100644 services/history-v1/test/acceptance/js/storage/files/graph.png create mode 100644 services/history-v1/test/acceptance/js/storage/files/hello.txt create mode 100644 services/history-v1/test/acceptance/js/storage/files/non_bmp.txt create mode 100644 services/history-v1/test/acceptance/js/storage/files/null_characters.txt create mode 100644 services/history-v1/test/acceptance/js/storage/fixtures/chunks.js create mode 100644 services/history-v1/test/acceptance/js/storage/fixtures/docs.js create mode 100644 services/history-v1/test/acceptance/js/storage/fixtures/index.js create mode 100644 services/history-v1/test/acceptance/js/storage/persist_changes.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/project_archive.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/project_key.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/cleanup.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/fetch.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/fixtures.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/test_files.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/unzip.js create mode 100644 services/history-v1/test/acceptance/js/storage/tasks.test.js create mode 100644 services/history-v1/test/acceptance/js/storage/zip_store.test.js create mode 100644 services/history-v1/test/setup.js create mode 100644 services/project-history/.eslintignore create mode 100644 services/project-history/.github/ISSUE_TEMPLATE.md create mode 100644 services/project-history/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 services/project-history/.gitignore create mode 100644 services/project-history/.mocharc.json create mode 100644 services/project-history/.nvmrc create mode 100644 services/project-history/Dockerfile create mode 100644 services/project-history/Makefile create mode 100644 services/project-history/README.md create mode 100644 services/project-history/app.js create mode 100644 services/project-history/app/js/BlobManager.js create mode 100644 services/project-history/app/js/ChunkTranslator.js create mode 100644 services/project-history/app/js/DiffGenerator.js create mode 100644 services/project-history/app/js/DiffManager.js create mode 100644 services/project-history/app/js/DocumentUpdaterManager.js create mode 100644 services/project-history/app/js/ErrorRecorder.js create mode 100644 services/project-history/app/js/Errors.js create mode 100644 services/project-history/app/js/FileTreeDiffGenerator.js create mode 100644 services/project-history/app/js/FlushManager.js create mode 100644 services/project-history/app/js/HashManager.js create mode 100644 services/project-history/app/js/HealthChecker.js create mode 100644 services/project-history/app/js/HistoryApiManager.js create mode 100644 services/project-history/app/js/HistoryStoreManager.js create mode 100644 services/project-history/app/js/HttpController.js create mode 100644 services/project-history/app/js/LabelsManager.js create mode 100644 services/project-history/app/js/LargeFileManager.js create mode 100644 services/project-history/app/js/LocalFileWriter.js create mode 100644 services/project-history/app/js/LockManager.js create mode 100644 services/project-history/app/js/OperationsCompressor.js create mode 100644 services/project-history/app/js/Profiler.js create mode 100644 services/project-history/app/js/RedisManager.js create mode 100644 services/project-history/app/js/RetryManager.js create mode 100644 services/project-history/app/js/Router.js create mode 100644 services/project-history/app/js/SnapshotManager.js create mode 100644 services/project-history/app/js/SummarizedUpdatesManager.js create mode 100644 services/project-history/app/js/SyncManager.js create mode 100644 services/project-history/app/js/UpdateCompressor.js create mode 100644 services/project-history/app/js/UpdateTranslator.js create mode 100644 services/project-history/app/js/UpdatesProcessor.js create mode 100644 services/project-history/app/js/Validation.js create mode 100644 services/project-history/app/js/Versions.js create mode 100644 services/project-history/app/js/WebApiManager.js create mode 100644 services/project-history/app/js/mongodb.js create mode 100644 services/project-history/app/js/server.js create mode 100644 services/project-history/buildscript.txt create mode 100644 services/project-history/config/settings.defaults.cjs create mode 100644 services/project-history/docker-compose.ci.yml create mode 100644 services/project-history/docker-compose.yml create mode 100644 services/project-history/nodemon.json create mode 100644 services/project-history/package.json create mode 100644 services/project-history/scripts/add_index_for_sync_state.js create mode 100644 services/project-history/scripts/clear_dangling_timestamps.js create mode 100755 services/project-history/scripts/clear_deleted.js create mode 100755 services/project-history/scripts/clear_deleted_history.js create mode 100755 services/project-history/scripts/clear_filestore_404.js create mode 100755 services/project-history/scripts/clear_project_version_out_of_order.js create mode 100755 services/project-history/scripts/debug_translate_updates.js create mode 100755 services/project-history/scripts/flush_all.js create mode 100755 services/project-history/scripts/force_resync.js create mode 100644 services/project-history/test/acceptance/fixtures/blobs/35c9bd86574d61dcadbce2fdd3d4a0684272c6ea create mode 100644 services/project-history/test/acceptance/fixtures/blobs/4f785a4c192155b240e3042b3a7388b47603f423 create mode 100644 services/project-history/test/acceptance/fixtures/blobs/c6654ea913979e13e22022653d284444f284a172 create mode 100644 services/project-history/test/acceptance/fixtures/blobs/e13c315d53aaef3aa34550a86b09cff091ace220 create mode 100644 services/project-history/test/acceptance/fixtures/blobs/f28571f561d198b87c24cc6a98b78e87b665e22d create mode 100644 services/project-history/test/acceptance/fixtures/chunks/0-3.json create mode 100644 services/project-history/test/acceptance/fixtures/chunks/4-6.json create mode 100644 services/project-history/test/acceptance/fixtures/chunks/7-8.json create mode 100644 services/project-history/test/acceptance/js/DeleteProjectTests.js create mode 100644 services/project-history/test/acceptance/js/DiffTests.js create mode 100644 services/project-history/test/acceptance/js/DiscardingUpdatesTests.js create mode 100644 services/project-history/test/acceptance/js/FileTreeDiffTests.js create mode 100644 services/project-history/test/acceptance/js/FlushManagerTests.js create mode 100644 services/project-history/test/acceptance/js/HealthCheckTests.js create mode 100644 services/project-history/test/acceptance/js/LabelsTests.js create mode 100644 services/project-history/test/acceptance/js/ReadingASnapshotTests.js create mode 100644 services/project-history/test/acceptance/js/RetryTests.js create mode 100644 services/project-history/test/acceptance/js/SendingUpdatesTests.js create mode 100644 services/project-history/test/acceptance/js/SummarisedUpdatesTests.js create mode 100644 services/project-history/test/acceptance/js/SyncTests.js create mode 100644 services/project-history/test/acceptance/js/helpers/HistoryId.js create mode 100644 services/project-history/test/acceptance/js/helpers/HistoryStoreClient.js create mode 100644 services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js create mode 100644 services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js create mode 100644 services/project-history/test/setup.js create mode 100644 services/project-history/test/unit/js/BlobManager/BlobManagerTests.js create mode 100644 services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js create mode 100644 services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js create mode 100644 services/project-history/test/unit/js/DiffManager/DiffManagerTests.js create mode 100644 services/project-history/test/unit/js/DocumentUpdaterManager/DocumentUpdaterManagerTests.js create mode 100644 services/project-history/test/unit/js/ErrorRecorder/ErrorRecorderTest.js create mode 100644 services/project-history/test/unit/js/HistoryStoreManager/HistoryStoreManagerTests.js create mode 100644 services/project-history/test/unit/js/HttpController/HttpControllerTests.js create mode 100644 services/project-history/test/unit/js/LabelsManager/LabelsManagerTests.js create mode 100644 services/project-history/test/unit/js/LockManager/LockManagerTests.js create mode 100644 services/project-history/test/unit/js/OperationsCompressor/OperationsCompressorTests.js create mode 100644 services/project-history/test/unit/js/RedisManager/RedisManagerTests.js create mode 100644 services/project-history/test/unit/js/RetryManager/RetryManagerTests.js create mode 100644 services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js create mode 100644 services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js create mode 100644 services/project-history/test/unit/js/SyncManager/SyncManagerTests.js create mode 100644 services/project-history/test/unit/js/UpdateCompressor/UpdateCompressorTests.js create mode 100644 services/project-history/test/unit/js/UpdateTranslator/UpdateTranslatorTests.js create mode 100644 services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js create mode 100644 services/project-history/test/unit/js/Versions/VersionTest.js create mode 100644 services/project-history/test/unit/js/WebApiManager/WebApiManagerTests.js diff --git a/libraries/overleaf-editor-core/.dockerignore b/libraries/overleaf-editor-core/.dockerignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/libraries/overleaf-editor-core/.dockerignore @@ -0,0 +1 @@ +node_modules/ diff --git a/libraries/overleaf-editor-core/.gitignore b/libraries/overleaf-editor-core/.gitignore new file mode 100644 index 0000000000..869500a2c7 --- /dev/null +++ b/libraries/overleaf-editor-core/.gitignore @@ -0,0 +1,5 @@ +/coverage +/node_modules + +# managed by monorepo$ bin/update_build_scripts +.npmrc diff --git a/libraries/overleaf-editor-core/.nvmrc b/libraries/overleaf-editor-core/.nvmrc new file mode 100644 index 0000000000..c85fa1bbef --- /dev/null +++ b/libraries/overleaf-editor-core/.nvmrc @@ -0,0 +1 @@ +16.17.1 diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt new file mode 100644 index 0000000000..1775946e52 --- /dev/null +++ b/libraries/overleaf-editor-core/buildscript.txt @@ -0,0 +1,9 @@ +overleaf-editor-core +--dependencies=None +--docker-repos=gcr.io/overleaf-ops +--env-add= +--env-pass-through= +--is-library=True +--node-version=16.17.1 +--public-repo=False +--script-version=4.1.0 diff --git a/libraries/overleaf-editor-core/index.js b/libraries/overleaf-editor-core/index.js new file mode 100644 index 0000000000..3c4471af8b --- /dev/null +++ b/libraries/overleaf-editor-core/index.js @@ -0,0 +1,26 @@ +exports.Author = require('./lib/author') +exports.AuthorList = require('./lib/author_list') +exports.Blob = require('./lib/blob') +exports.Change = require('./lib/change') +exports.ChangeRequest = require('./lib/change_request') +exports.ChangeNote = require('./lib/change_note') +exports.Chunk = require('./lib/chunk') +exports.ChunkResponse = require('./lib/chunk_response') +exports.File = require('./lib/file') +exports.FileMap = require('./lib/file_map') +exports.History = require('./lib/history') +exports.Label = require('./lib/label') +exports.AddFileOperation = require('./lib/operation/add_file_operation') +exports.MoveFileOperation = require('./lib/operation/move_file_operation') +exports.EditFileOperation = require('./lib/operation/edit_file_operation') +exports.SetFileMetadataOperation = require('./lib/operation/set_file_metadata_operation') +exports.NoOperation = require('./lib/operation/no_operation') +exports.Operation = require('./lib/operation') +exports.RestoreOrigin = require('./lib/origin/restore_origin') +exports.Origin = require('./lib/origin') +exports.OtClient = require('./lib/ot_client') +exports.TextOperation = require('./lib/operation/text_operation') +exports.safePathname = require('./lib/safe_pathname') +exports.Snapshot = require('./lib/snapshot') +exports.util = require('./lib/util') +exports.V2DocVersions = require('./lib/v2_doc_versions') diff --git a/libraries/overleaf-editor-core/lib/author.js b/libraries/overleaf-editor-core/lib/author.js new file mode 100644 index 0000000000..7a49ac65f4 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/author.js @@ -0,0 +1,70 @@ +'use strict' + +const assert = require('check-types').assert + +/** + * @constructor + * @param {number} id + * @param {string} email + * @param {string} name + * @classdesc + * An author of a {@link Change}. We want to store user IDs, and then fill in + * the other properties (which the user can change over time) when changes are + * loaded. + * + * At present, we're assuming that all authors have a user ID; we may need to + * generalise this to cover users for whom we only have a name and email, e.g. + * from git. For now, though, this seems to do what we need. + */ +function Author(id, email, name) { + assert.number(id, 'bad id') + assert.string(email, 'bad email') + assert.string(name, 'bad name') + + this.id = id + this.email = email + this.name = name +} + +/** + * Create an Author from its raw form. + * + * @param {Object} [raw] + * @return {Author | null} + */ +Author.fromRaw = function authorFromRaw(raw) { + if (!raw) return null + return new Author(raw.id, raw.email, raw.name) +} + +/** + * Convert the Author to raw form for storage or transmission. + * + * @return {Object} + */ +Author.prototype.toRaw = function authorToRaw() { + return { id: this.id, email: this.email, name: this.name } +} + +/** + * @return {number} + */ +Author.prototype.getId = function () { + return this.id +} + +/** + * @return {string} + */ +Author.prototype.getEmail = function () { + return this.email +} + +/** + * @return {string} + */ +Author.prototype.getName = function () { + return this.name +} + +module.exports = Author diff --git a/libraries/overleaf-editor-core/lib/author_list.js b/libraries/overleaf-editor-core/lib/author_list.js new file mode 100644 index 0000000000..8dcc857183 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/author_list.js @@ -0,0 +1,45 @@ +/** @module */ + +'use strict' + +const _ = require('lodash') +const check = require('check-types') + +const Author = require('./author') + +/** + * Check that every member of the list is a number or every member is + * an Author value, disregarding null or undefined values. + * + * @param {Array.} authors author list + * @param {string} msg + */ +function assertV1(authors, msg) { + const authors_ = authors.filter(function (a) { + return a !== null && a !== undefined + }) + + if (authors_.length > 0) { + const checker = check.integer(authors_[0]) + ? check.assert.integer + : _.partial(check.assert.instance, _, Author) + _.each(authors_, function (author) { + checker(author, msg) + }) + } +} + +/** + * Check that every member of the list is a v2 author ID, disregarding + * null or undefined values. + * + * @param {Array.} authors author list + * @param {string} msg + */ +function assertV2(authors, msg) { + _.each(authors, function (author) { + check.assert.maybe.match(author, /^[0-9a-f]{24}$/, msg) + }) +} + +module.exports = { assertV1: assertV1, assertV2: assertV2 } diff --git a/libraries/overleaf-editor-core/lib/blob.js b/libraries/overleaf-editor-core/lib/blob.js new file mode 100644 index 0000000000..3b7947fdf5 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/blob.js @@ -0,0 +1,100 @@ +'use strict' + +const assert = require('check-types').assert +const OError = require('@overleaf/o-error') + +const TextOperation = require('./operation/text_operation') + +/** + * @constructor + * @classdesc + * Metadata record for the content of a file. + */ +function Blob(hash, byteLength, stringLength) { + this.setHash(hash) + this.setByteLength(byteLength) + this.setStringLength(stringLength) +} + +class NotFoundError extends OError { + constructor(hash) { + super(`blob ${hash} not found`, { hash }) + this.hash = hash + } +} +Blob.NotFoundError = NotFoundError + +Blob.HEX_HASH_RX_STRING = '^[0-9a-f]{40,40}$' +Blob.HEX_HASH_RX = new RegExp(Blob.HEX_HASH_RX_STRING) + +module.exports = Blob + +Blob.fromRaw = function blobFromRaw(raw) { + if (raw) { + return new Blob(raw.hash, raw.byteLength, raw.stringLength) + } + return null +} + +Blob.prototype.toRaw = function blobToRaw() { + return { + hash: this.hash, + byteLength: this.byteLength, + stringLength: this.stringLength, + } +} + +/** + * Hex hash. + * @return {?String} + */ +Blob.prototype.getHash = function () { + return this.hash +} +Blob.prototype.setHash = function (hash) { + assert.maybe.match(hash, Blob.HEX_HASH_RX, 'bad hash') + this.hash = hash +} + +/** + * Length of the blob in bytes. + * @return {number} + */ +Blob.prototype.getByteLength = function () { + return this.byteLength +} +Blob.prototype.setByteLength = function (byteLength) { + assert.maybe.integer(byteLength, 'bad byteLength') + this.byteLength = byteLength +} + +/** + * Utf-8 length of the blob content, if it appears to be valid UTF-8. + * @return {?number} + */ +Blob.prototype.getStringLength = function () { + return this.stringLength +} +Blob.prototype.setStringLength = function (stringLength) { + assert.maybe.integer(stringLength, 'bad stringLength') + this.stringLength = stringLength +} + +/** + * Size of the largest file that we'll read to determine whether we can edit it + * or not, in bytes. The final decision on whether a file is editable or not is + * based on the number of characters it contains, but we need to read the file + * in to determine that; so it is useful to have an upper bound on the byte + * length of a file that might be editable. + * + * The reason for the factor of 3 is as follows. We cannot currently edit files + * that contain characters outside of the basic multilingual plane, so we're + * limited to characters that can be represented in a single, two-byte UCS-2 + * code unit. Encoding the largest such value, 0xFFFF (which is not actually + * a valid character), takes three bytes in UTF-8: 0xEF 0xBF 0xBF. A file + * composed entirely of three-byte UTF-8 codepoints is the worst case; in + * practice, this is a very conservative upper bound. + * + * @type {number} + */ +Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND = 3 * TextOperation.MAX_STRING_LENGTH diff --git a/libraries/overleaf-editor-core/lib/change.js b/libraries/overleaf-editor-core/lib/change.js new file mode 100644 index 0000000000..36bfcd3a05 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/change.js @@ -0,0 +1,333 @@ +'use strict' + +const _ = require('lodash') +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const AuthorList = require('./author_list') +const Operation = require('./operation') +const Origin = require('./origin') +const Snapshot = require('./snapshot') +const FileMap = require('./file_map') +const V2DocVersions = require('./v2_doc_versions') + +/** + * @typedef {import("./author")} Author + * @typedef {import("./types").BlobStore} BlobStore + */ + +/** + * @classdesc + * A Change is a list of {@link Operation}s applied atomically by given + * {@link Author}(s) at a given time. + */ +class Change { + /** + * @constructor + * @param {Array.} operations + * @param {Date} timestamp + * @param {number[] | Author[]} [authors] + * @param {Origin} [origin] + * @param {string[]} [v2Authors] + * @param {string} [projectVersion] + * @param {V2DocVersions} [v2DocVersions] + */ + constructor( + operations, + timestamp, + authors, + origin, + v2Authors, + projectVersion, + v2DocVersions + ) { + this.setOperations(operations) + this.setTimestamp(timestamp) + this.setAuthors(authors || []) + this.setOrigin(origin) + this.setV2Authors(v2Authors || []) + this.setProjectVersion(projectVersion) + this.setV2DocVersions(v2DocVersions) + } + + /** + * For serialization. + * + * @return {Object} + */ + toRaw() { + function toRaw(object) { + return object.toRaw() + } + const raw = { + operations: this.operations.map(toRaw), + timestamp: this.timestamp.toISOString(), + authors: this.authors, + } + if (this.v2Authors) raw.v2Authors = this.v2Authors + if (this.origin) raw.origin = this.origin.toRaw() + if (this.projectVersion) raw.projectVersion = this.projectVersion + if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw() + return raw + } + + static fromRaw(raw) { + if (!raw) return null + assert.array.of.object(raw.operations, 'bad raw.operations') + assert.nonEmptyString(raw.timestamp, 'bad raw.timestamp') + + // Hack to clean up bad data where author id of some changes was 0, instead of + // null. The root cause of the bug is fixed in + // https://github.com/overleaf/write_latex/pull/3804 but the bad data persists + // on S3 + let authors + if (raw.authors) { + authors = raw.authors.map( + // Null represents an anonymous author + author => (author === 0 ? null : author) + ) + } + + return new Change( + raw.operations.map(Operation.fromRaw), + new Date(raw.timestamp), + authors, + raw.origin && Origin.fromRaw(raw.origin), + raw.v2Authors, + raw.projectVersion, + raw.v2DocVersions && V2DocVersions.fromRaw(raw.v2DocVersions) + ) + } + + getOperations() { + return this.operations + } + + setOperations(operations) { + assert.array.of.object(operations, 'Change: bad operations') + this.operations = operations + } + + getTimestamp() { + return this.timestamp + } + + setTimestamp(timestamp) { + assert.date(timestamp, 'Change: bad timestamp') + this.timestamp = timestamp + } + + /** + * @return {Array.} zero or more + */ + getAuthors() { + return this.authors + } + + setAuthors(authors) { + assert.array(authors, 'Change: bad author ids array') + if (authors.length > 1) { + assert.maybe.emptyArray( + this.v2Authors, + 'Change: cannot set v1 authors if v2 authors is set' + ) + } + AuthorList.assertV1(authors, 'Change: bad author ids') + + this.authors = authors + } + + /** + * @return {Array.} zero or more + */ + getV2Authors() { + return this.v2Authors + } + + setV2Authors(v2Authors) { + assert.array(v2Authors, 'Change: bad v2 author ids array') + if (v2Authors.length > 1) { + assert.maybe.emptyArray( + this.authors, + 'Change: cannot set v2 authors if v1 authors is set' + ) + } + AuthorList.assertV2(v2Authors, 'Change: not a v2 author id') + this.v2Authors = v2Authors + } + + /** + * @return {Origin | null | undefined} + */ + getOrigin() { + return this.origin + } + + setOrigin(origin) { + assert.maybe.instance(origin, Origin, 'Change: bad origin') + this.origin = origin + } + + /** + * @return {string | null | undefined} + */ + getProjectVersion() { + return this.projectVersion + } + + setProjectVersion(projectVersion) { + assert.maybe.match( + projectVersion, + Change.PROJECT_VERSION_RX, + 'Change: bad projectVersion' + ) + this.projectVersion = projectVersion + } + + /** + * @return {V2DocVersions | null | undefined} + */ + getV2DocVersions() { + return this.v2DocVersions + } + + setV2DocVersions(v2DocVersions) { + assert.maybe.instance( + v2DocVersions, + V2DocVersions, + 'Change: bad v2DocVersions' + ) + this.v2DocVersions = v2DocVersions + } + + /** + * If this Change references blob hashes, add them to the given set. + * + * @param {Set.} blobHashes + */ + findBlobHashes(blobHashes) { + for (const operation of this.operations) { + operation.findBlobHashes(blobHashes) + } + } + + /** + * If this Change contains any File objects, load them. + * + * @param {string} kind see {File#load} + * @param {BlobStore} blobStore + * @return {Promise} + */ + loadFiles(kind, blobStore) { + return BPromise.each(this.operations, operation => + operation.loadFiles(kind, blobStore) + ) + } + + /** + * Append an operation to the end of the operations list. + * + * @param {Operation} operation + * @return {this} + */ + pushOperation(operation) { + this.getOperations().push(operation) + return this + } + + /** + * Apply this change to a snapshot. All operations are applied, and then the + * snapshot version is increased. + * + * Recoverable errors (caused by historical bad data) are ignored unless + * opts.strict is true + * + * @param {Snapshot} snapshot modified in place + * @param {object} opts + * @param {boolean} [opts.strict] - Do not ignore recoverable errors + */ + applyTo(snapshot, opts = {}) { + assert.object(snapshot, 'bad snapshot') + + for (const operation of this.operations) { + try { + operation.applyTo(snapshot, opts) + } catch (err) { + const recoverable = + err instanceof Snapshot.EditMissingFileError || + err instanceof FileMap.FileNotFoundError + if (!recoverable || opts.strict) { + throw err + } + } + } + + // update project version if present in change + if (this.projectVersion) { + snapshot.setProjectVersion(this.projectVersion) + } + + // update doc versions + if (this.v2DocVersions) { + snapshot.updateV2DocVersions(this.v2DocVersions) + } + } + + /** + * Transform this change to account for the fact that the other change occurred + * simultaneously and was applied first. + * + * This change is modified in place (by transforming its operations). + * + * @param {Change} other + */ + transformAfter(other) { + assert.object(other, 'bad other') + + const thisOperations = this.getOperations() + const otherOperations = other.getOperations() + for (let i = 0; i < otherOperations.length; ++i) { + for (let j = 0; j < thisOperations.length; ++j) { + thisOperations[j] = Operation.transform( + thisOperations[j], + otherOperations[i] + )[0] + } + } + } + + clone() { + return Change.fromRaw(this.toRaw()) + } + + store(blobStore, concurrency) { + assert.maybe.number(concurrency, 'bad concurrency') + + const raw = this.toRaw() + raw.authors = _.uniq(raw.authors) + + return BPromise.map( + this.operations, + operation => operation.store(blobStore), + { concurrency: concurrency || 1 } + ).then(rawOperations => { + raw.operations = rawOperations + return raw + }) + } + + canBeComposedWith(other) { + const operations = this.getOperations() + const otherOperations = other.getOperations() + + // We ignore complex changes with more than 1 operation + if (operations.length > 1 || otherOperations.length > 1) return false + + return operations[0].canBeComposedWith(otherOperations[0]) + } +} + +Change.PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$' +Change.PROJECT_VERSION_RX = new RegExp(Change.PROJECT_VERSION_RX_STRING) + +module.exports = Change diff --git a/libraries/overleaf-editor-core/lib/change_note.js b/libraries/overleaf-editor-core/lib/change_note.js new file mode 100644 index 0000000000..b8d21ca0cf --- /dev/null +++ b/libraries/overleaf-editor-core/lib/change_note.js @@ -0,0 +1,60 @@ +'use strict' + +const assert = require('check-types').assert + +const Change = require('./change') + +/** + * @constructor + * @param {number} baseVersion the new base version for the change + * @param {?Change} change + * @classdesc + * A `ChangeNote` is returned when the server has applied a {@link Change}. + */ +function ChangeNote(baseVersion, change) { + assert.integer(baseVersion, 'bad baseVersion') + assert.maybe.instance(change, Change, 'bad change') + + this.baseVersion = baseVersion + this.change = change +} + +module.exports = ChangeNote + +/** + * For serialization. + * + * @return {Object} + */ +ChangeNote.prototype.toRaw = function changeNoteToRaw() { + return { + baseVersion: this.baseVersion, + change: this.change.toRaw(), + } +} + +ChangeNote.prototype.toRawWithoutChange = + function changeNoteToRawWithoutChange() { + return { + baseVersion: this.baseVersion, + } + } + +ChangeNote.fromRaw = function changeNoteFromRaw(raw) { + assert.integer(raw.baseVersion, 'bad raw.baseVersion') + assert.maybe.object(raw.change, 'bad raw.changes') + + return new ChangeNote(raw.baseVersion, Change.fromRaw(raw.change)) +} + +ChangeNote.prototype.getBaseVersion = function () { + return this.baseVersion +} + +ChangeNote.prototype.getResultVersion = function () { + return this.baseVersion + 1 +} + +ChangeNote.prototype.getChange = function () { + return this.change +} diff --git a/libraries/overleaf-editor-core/lib/change_request.js b/libraries/overleaf-editor-core/lib/change_request.js new file mode 100644 index 0000000000..c8d7bfe626 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/change_request.js @@ -0,0 +1,90 @@ +'use strict' + +const assert = require('check-types').assert + +const AuthorList = require('./author_list') +const Change = require('./change') +const Operation = require('./operation') + +/** + * @typedef {import("./author")} Author + */ + +/** + * @constructor + * @param {number} baseVersion + * @param {Array.} operations + * @param {boolean} [untransformable] + * @param {number[] | Author[]} [authors] + * @classdesc + * A `ChangeRequest` is a list of {@link Operation}s that the server can apply + * as a {@link Change}. + * + * If the change is marked as `untransformable`, then the server will not + * attempt to transform it if it is out of date (i.e. if the baseVersion no + * longer matches the project's latest version). For example, if the client + * needs to ensure that a metadata property is set on exactly one file, it can't + * do that reliably if there's a chance that other clients will also change the + * metadata at the same time. The expectation is that if the change is rejected, + * the client will retry on a later version. + */ +function ChangeRequest(baseVersion, operations, untransformable, authors) { + assert.integer(baseVersion, 'bad baseVersion') + assert.array.of.object(operations, 'bad operations') + assert.maybe.boolean(untransformable, 'ChangeRequest: bad untransformable') + // TODO remove authors once we have JWTs working --- pass as parameter to + // makeChange instead + authors = authors || [] + + // check all are the same type + AuthorList.assertV1(authors, 'bad authors') + + this.authors = authors + this.baseVersion = baseVersion + this.operations = operations + this.untransformable = untransformable || false +} + +module.exports = ChangeRequest + +/** + * For serialization. + * + * @return {Object} + */ +ChangeRequest.prototype.toRaw = function changeRequestToRaw() { + function operationToRaw(operation) { + return operation.toRaw() + } + + return { + baseVersion: this.baseVersion, + operations: this.operations.map(operationToRaw), + untransformable: this.untransformable, + authors: this.authors, + } +} + +ChangeRequest.fromRaw = function changeRequestFromRaw(raw) { + assert.array.of.object(raw.operations, 'bad raw.operations') + return new ChangeRequest( + raw.baseVersion, + raw.operations.map(Operation.fromRaw), + raw.untransformable, + raw.authors + ) +} + +ChangeRequest.prototype.getBaseVersion = function () { + return this.baseVersion +} + +ChangeRequest.prototype.isUntransformable = function () { + return this.untransformable +} + +ChangeRequest.prototype.makeChange = function changeRequestMakeChange( + timestamp +) { + return new Change(this.operations, timestamp, this.authors) +} diff --git a/libraries/overleaf-editor-core/lib/chunk.js b/libraries/overleaf-editor-core/lib/chunk.js new file mode 100644 index 0000000000..8ddf9625d3 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/chunk.js @@ -0,0 +1,166 @@ +'use strict' + +const assert = require('check-types').assert +const OError = require('@overleaf/o-error') + +const History = require('./history') + +/** + * @typedef {import("./types").BlobStore} BlobStore + * @typedef {import("./change")} Change + * @typedef {import("./snapshot")} Snapshot + */ + +/** + * @constructor + * @param {History} history + * @param {number} startVersion + * + * @classdesc + * A Chunk is a {@link History} that is part of a project's overall history. It + * has a start and an end version that place its History in context. + */ +function Chunk(history, startVersion) { + assert.instance(history, History, 'bad history') + assert.integer(startVersion, 'bad startVersion') + + this.history = history + this.startVersion = startVersion +} + +class ConflictingEndVersion extends OError { + constructor(clientEndVersion, latestEndVersion) { + const message = + 'client sent updates with end_version ' + + clientEndVersion + + ' but latest chunk has end_version ' + + latestEndVersion + super(message, { clientEndVersion, latestEndVersion }) + this.clientEndVersion = clientEndVersion + this.latestEndVersion = latestEndVersion + } +} +Chunk.ConflictingEndVersion = ConflictingEndVersion + +class NotFoundError extends OError { + // `message` and `info` optional arguments allow children classes to override + // these values, ensuring backwards compatibility with previous implementation + // based on the `overleaf-error-type` library + constructor(projectId, message, info) { + const errorMessage = message || `no chunks for project ${projectId}` + const errorInfo = info || { projectId } + super(errorMessage, errorInfo) + this.projectId = projectId + } +} +Chunk.NotFoundError = NotFoundError + +class VersionNotFoundError extends NotFoundError { + constructor(projectId, version) { + super(projectId, `chunk for ${projectId} v ${version} not found`, { + projectId, + version, + }) + this.projectId = projectId + this.version = version + } +} +Chunk.VersionNotFoundError = VersionNotFoundError + +class BeforeTimestampNotFoundError extends NotFoundError { + constructor(projectId, timestamp) { + super(projectId, `chunk for ${projectId} timestamp ${timestamp} not found`) + this.projectId = projectId + this.timestamp = timestamp + } +} +Chunk.BeforeTimestampNotFoundError = BeforeTimestampNotFoundError + +class NotPersistedError extends NotFoundError { + constructor(projectId) { + super(projectId, `chunk for ${projectId} not persisted yet`) + this.projectId = projectId + } +} +Chunk.NotPersistedError = NotPersistedError + +Chunk.fromRaw = function chunkFromRaw(raw) { + return new Chunk(History.fromRaw(raw.history), raw.startVersion) +} + +Chunk.prototype.toRaw = function chunkToRaw() { + return { history: this.history.toRaw(), startVersion: this.startVersion } +} + +/** + * The history for this chunk. + * + * @return {History} + */ +Chunk.prototype.getHistory = function () { + return this.history +} + +/** + * {@see History#getSnapshot} + * @return {Snapshot} + */ +Chunk.prototype.getSnapshot = function () { + return this.history.getSnapshot() +} + +/** + * {@see History#getChanges} + * @return {Array.} + */ +Chunk.prototype.getChanges = function () { + return this.history.getChanges() +} + +/** + * {@see History#pushChanges} + * @param {Array.} changes + */ +Chunk.prototype.pushChanges = function chunkPushChanges(changes) { + this.history.pushChanges(changes) +} + +/** + * The version of the project after applying all changes in this chunk. + * + * @return {number} non-negative, greater than or equal to start version + */ +Chunk.prototype.getEndVersion = function chunkGetEndVersion() { + return this.startVersion + this.history.countChanges() +} + +/** + * The timestamp of the last change in this chunk + */ + +Chunk.prototype.getEndTimestamp = function getEndTimestamp() { + if (!this.history.countChanges()) return null + return this.history.getChanges().slice(-1)[0].getTimestamp() +} + +/** + * The version of the project before applying all changes in this chunk. + * + * @return {number} non-negative, less than or equal to end version + */ +Chunk.prototype.getStartVersion = function () { + return this.startVersion +} + +/** + * {@see History#loadFiles} + * + * @param {string} kind + * @param {BlobStore} blobStore + * @return {Promise} + */ +Chunk.prototype.loadFiles = function chunkLoadFiles(kind, blobStore) { + return this.history.loadFiles(kind, blobStore) +} + +module.exports = Chunk diff --git a/libraries/overleaf-editor-core/lib/chunk_response.js b/libraries/overleaf-editor-core/lib/chunk_response.js new file mode 100644 index 0000000000..84ad2c2559 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/chunk_response.js @@ -0,0 +1,32 @@ +'use strict' + +const assert = require('check-types').assert +const Chunk = require('./chunk') + +// +// The ChunkResponse allows for additional data to be sent back with the chunk +// at present there are no extra data to send. +// + +function ChunkResponse(chunk) { + assert.instance(chunk, Chunk) + this.chunk = chunk +} + +ChunkResponse.prototype.toRaw = function chunkResponseToRaw() { + return { + chunk: this.chunk.toRaw(), + } +} + +ChunkResponse.fromRaw = function chunkResponseFromRaw(raw) { + if (!raw) return null + + return new ChunkResponse(Chunk.fromRaw(raw.chunk)) +} + +ChunkResponse.prototype.getChunk = function () { + return this.chunk +} + +module.exports = ChunkResponse diff --git a/libraries/overleaf-editor-core/lib/file.js b/libraries/overleaf-editor-core/lib/file.js new file mode 100644 index 0000000000..78826c04d2 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file.js @@ -0,0 +1,241 @@ +'use strict' + +const _ = require('lodash') +const assert = require('check-types').assert + +const OError = require('@overleaf/o-error') +const FileData = require('./file_data') +const HashFileData = require('./file_data/hash_file_data') +const StringFileData = require('./file_data/string_file_data') + +/** + * @typedef {import("./blob")} Blob + * @typedef {import("./types").BlobStore} BlobStore + * @typedef {import("./types").StringFileRawData} StringFileRawData + * @typedef {import("./operation/text_operation")} TextOperation + */ + +/** + * @template T + * @typedef {import("bluebird")} BPromise + */ + +/** + * @constructor + * @param {FileData} data + * @param {Object} [metadata] + * + * @classdesc + * A file in a {@link Snapshot}. A file has both data and metadata. There + * are several classes of data that represent the various types of file + * data that are supported, namely text and binary, and also the various + * states that a file's data can be in, namely: + * + * 1. Hash only: all we know is the file's hash; this is how we encode file + * content in long term storage. + * 1. Lazily loaded: the hash of the file, its length, and its type are known, + * but its content is not loaded. Operations are cached for application + * later. + * 1. Eagerly loaded: the content of a text file is fully loaded into memory + * as a string. + * 1. Hollow: only the byte and/or UTF-8 length of the file are known; this is + * used to allow for validation of operations when editing collaboratively + * without having to keep file data in memory on the server. + */ +function File(data, metadata) { + assert.instance(data, FileData, 'File: bad data') + + this.data = data + this.setMetadata(metadata || {}) +} + +File.fromRaw = function fileFromRaw(raw) { + if (!raw) return null + return new File(FileData.fromRaw(raw), raw.metadata) +} + +/** + * @param {string} hash + * @param {Object} [metadata] + * @return {File} + */ +File.fromHash = function fileFromHash(hash, metadata) { + return new File(new HashFileData(hash), metadata) +} + +/** + * @param {string} string + * @param {Object} [metadata] + * @return {File} + */ +File.fromString = function fileFromString(string, metadata) { + return new File(new StringFileData(string), metadata) +} + +/** + * @param {number} [byteLength] + * @param {number} [stringLength] + * @param {Object} [metadata] + * @return {File} + */ +File.createHollow = function fileCreateHollow( + byteLength, + stringLength, + metadata +) { + return new File(FileData.createHollow(byteLength, stringLength), metadata) +} + +/** + * @param {Blob} blob + * @param {Object} [metadata] + * @return {File} + */ +File.createLazyFromBlob = function fileCreateLazyFromBlob(blob, metadata) { + return new File(FileData.createLazyFromBlob(blob), metadata) +} + +function storeRawMetadata(metadata, raw) { + if (!_.isEmpty(metadata)) { + raw.metadata = _.cloneDeep(metadata) + } +} + +File.prototype.toRaw = function () { + const rawFileData = this.data.toRaw() + storeRawMetadata(this.metadata, rawFileData) + return rawFileData +} + +/** + * Hexadecimal SHA-1 hash of the file's content, if known. + * + * @return {string | null | undefined} + */ +File.prototype.getHash = function () { + return this.data.getHash() +} + +/** + * The content of the file, if it is known and if this file has UTF-8 encoded + * content. + * + * @return {string | null | undefined} + */ +File.prototype.getContent = function () { + return this.data.getContent() +} + +/** + * Whether this file has string content and is small enough to be edited using + * {@link TextOperation}s. + * + * @return {boolean | null | undefined} null if it is not currently known + */ +File.prototype.isEditable = function () { + return this.data.isEditable() +} + +/** + * The length of the file's content in bytes, if known. + * + * @return {number | null | undefined} + */ +File.prototype.getByteLength = function () { + return this.data.getByteLength() +} + +/** + * The length of the file's content in characters, if known. + * + * @return {number | null | undefined} + */ +File.prototype.getStringLength = function () { + return this.data.getStringLength() +} + +/** + * Return the metadata object for this file. + * + * @return {Object} + */ +File.prototype.getMetadata = function () { + return this.metadata +} + +/** + * Set the metadata object for this file. + * + * @param {Object} metadata + */ +File.prototype.setMetadata = function (metadata) { + assert.object(metadata, 'File: bad metadata') + this.metadata = metadata +} + +class NotEditableError extends OError { + constructor() { + super('File is not editable') + } +} + +File.NotEditableError = NotEditableError + +/** + * Edit this file, if possible. + * + * @param {TextOperation} textOperation + */ +File.prototype.edit = function (textOperation) { + if (!this.data.isEditable()) throw new File.NotEditableError() + this.data.edit(textOperation) +} + +/** + * Clone a file. + * + * @return {File} a new object of the same type + */ +File.prototype.clone = function fileClone() { + return File.fromRaw(this.toRaw()) +} + +/** + * Convert this file's data to the given kind. This may require us to load file + * size or content from the given blob store, so this is an asynchronous + * operation. + * + * @param {string} kind + * @param {BlobStore} blobStore + * @return {Promise.} for this + */ +File.prototype.load = function (kind, blobStore) { + return this.data.load(kind, blobStore).then(data => { + this.data = data + return this + }) +} + +/** + * Store the file's content in the blob store and return a raw file with + * the corresponding hash. As a side effect, make this object consistent with + * the hash. + * + * @param {BlobStore} blobStore + * @return {BPromise} a raw HashFile + */ +File.prototype.store = function (blobStore) { + return this.data.store(blobStore).then(raw => { + storeRawMetadata(this.metadata, raw) + return raw + }) +} + +/** + * Blob hash for an empty file. + * + * @type {String} + */ +File.EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' + +module.exports = File diff --git a/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js b/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js new file mode 100644 index 0000000000..88f74dc6ee --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js @@ -0,0 +1,71 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const Blob = require('../blob') +const FileData = require('./') + +class BinaryFileData extends FileData { + /** + * @constructor + * @param {string} hash + * @param {number} byteLength + * @see FileData + */ + constructor(hash, byteLength) { + super() + assert.match(hash, Blob.HEX_HASH_RX, 'BinaryFileData: bad hash') + assert.integer(byteLength, 'BinaryFileData: bad byteLength') + assert.greaterOrEqual(byteLength, 0, 'BinaryFileData: low byteLength') + + this.hash = hash + this.byteLength = byteLength + } + + static fromRaw(raw) { + return new BinaryFileData(raw.hash, raw.byteLength) + } + + /** @inheritdoc */ + toRaw() { + return { hash: this.hash, byteLength: this.byteLength } + } + + /** @inheritdoc */ + getHash() { + return this.hash + } + + /** @inheritdoc */ + isEditable() { + return false + } + + /** @inheritdoc */ + getByteLength() { + return this.byteLength + } + + /** @inheritdoc */ + toEager() { + return BPromise.resolve(this) + } + + /** @inheritdoc */ + toLazy() { + return BPromise.resolve(this) + } + + /** @inheritdoc */ + toHollow() { + return BPromise.try(() => FileData.createHollow(this.byteLength, null)) + } + + /** @inheritdoc */ + store() { + return BPromise.resolve({ hash: this.hash }) + } +} + +module.exports = BinaryFileData diff --git a/libraries/overleaf-editor-core/lib/file_data/hash_file_data.js b/libraries/overleaf-editor-core/lib/file_data/hash_file_data.js new file mode 100644 index 0000000000..719664c38d --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/hash_file_data.js @@ -0,0 +1,63 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const Blob = require('../blob') +const FileData = require('./') + +class HashFileData extends FileData { + /** + * @constructor + * @param {string} hash + * @see FileData + */ + constructor(hash) { + super() + assert.match(hash, Blob.HEX_HASH_RX, 'HashFileData: bad hash') + this.hash = hash + } + + static fromRaw(raw) { + return new HashFileData(raw.hash) + } + + /** @inheritdoc */ + toRaw() { + return { hash: this.hash } + } + + /** @inheritdoc */ + getHash() { + return this.hash + } + + /** @inheritdoc */ + toEager(blobStore) { + return this.toLazy(blobStore).then(lazyFileData => + lazyFileData.toEager(blobStore) + ) + } + + /** @inheritdoc */ + toLazy(blobStore) { + return blobStore.getBlob(this.hash).then(blob => { + if (!blob) throw new Error('blob not found: ' + this.hash) + return FileData.createLazyFromBlob(blob) + }) + } + + /** @inheritdoc */ + toHollow(blobStore) { + return blobStore.getBlob(this.hash).then(function (blob) { + return FileData.createHollow(blob.getByteLength(), blob.getStringLength()) + }) + } + + /** @inheritdoc */ + store() { + return BPromise.resolve({ hash: this.hash }) + } +} + +module.exports = HashFileData diff --git a/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js b/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js new file mode 100644 index 0000000000..fb444a5ddd --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js @@ -0,0 +1,46 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const FileData = require('./') + +class HollowBinaryFileData extends FileData { + /** + * @constructor + * @param {number} byteLength + * @see FileData + */ + constructor(byteLength) { + super() + assert.integer(byteLength, 'HollowBinaryFileData: bad byteLength') + assert.greaterOrEqual(byteLength, 0, 'HollowBinaryFileData: low byteLength') + this.byteLength = byteLength + } + + static fromRaw(raw) { + return new HollowBinaryFileData(raw.byteLength) + } + + /** @inheritdoc */ + toRaw() { + return { byteLength: this.byteLength } + } + + /** @inheritdoc */ + getByteLength() { + return this.byteLength + } + + /** @inheritdoc */ + isEditable() { + return false + } + + /** @inheritdoc */ + toHollow() { + return BPromise.resolve(this) + } +} + +module.exports = HollowBinaryFileData diff --git a/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js new file mode 100644 index 0000000000..9be9253cbe --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js @@ -0,0 +1,55 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const FileData = require('./') + +class HollowStringFileData extends FileData { + /** + * @constructor + * @param {number} stringLength + * @see FileData + */ + constructor(stringLength) { + super() + assert.integer(stringLength, 'HollowStringFileData: bad stringLength') + assert.greaterOrEqual( + stringLength, + 0, + 'HollowStringFileData: low stringLength' + ) + this.stringLength = stringLength + } + + static fromRaw(raw) { + return new HollowStringFileData(raw.stringLength) + } + + /** @inheritdoc */ + toRaw() { + return { stringLength: this.stringLength } + } + + /** @inheritdoc */ + getStringLength() { + return this.stringLength + } + + /** @inheritdoc */ + isEditable() { + return true + } + + /** @inheritdoc */ + toHollow() { + return BPromise.resolve(this) + } + + /** @inheritdoc */ + edit(textOperation) { + this.stringLength = textOperation.applyToLength(this.stringLength) + } +} + +module.exports = HollowStringFileData diff --git a/libraries/overleaf-editor-core/lib/file_data/index.js b/libraries/overleaf-editor-core/lib/file_data/index.js new file mode 100644 index 0000000000..3b9b4b7a77 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/index.js @@ -0,0 +1,169 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const Blob = require('../blob') + +// Dependencies are loaded at the bottom of the file to mitigate circular +// dependency +let BinaryFileData = null +let HashFileData = null +let HollowBinaryFileData = null +let HollowStringFileData = null +let LazyStringFileData = null +let StringFileData = null + +/** + * @typedef {import("../types").BlobStore} BlobStore + */ + +/** + * @classdesc + * Helper to represent the content of a file. This class and its subclasses + * should be used only through {@link File}. + */ +class FileData { + /** @see File.fromRaw */ + static fromRaw(raw) { + if (Object.prototype.hasOwnProperty.call(raw, 'hash')) { + if (Object.prototype.hasOwnProperty.call(raw, 'byteLength')) + return BinaryFileData.fromRaw(raw) + if (Object.prototype.hasOwnProperty.call(raw, 'stringLength')) + return LazyStringFileData.fromRaw(raw) + return HashFileData.fromRaw(raw) + } + if (Object.prototype.hasOwnProperty.call(raw, 'byteLength')) + return HollowBinaryFileData.fromRaw(raw) + if (Object.prototype.hasOwnProperty.call(raw, 'stringLength')) + return HollowStringFileData.fromRaw(raw) + if (Object.prototype.hasOwnProperty.call(raw, 'content')) + return StringFileData.fromRaw(raw) + throw new Error('FileData: bad raw object ' + JSON.stringify(raw)) + } + + /** @see File.createHollow */ + static createHollow(byteLength, stringLength) { + if (stringLength == null) { + return new HollowBinaryFileData(byteLength) + } + return new HollowStringFileData(stringLength) + } + + /** @see File.createLazyFromBlob */ + static createLazyFromBlob(blob) { + assert.instance(blob, Blob, 'FileData: bad blob') + if (blob.getStringLength() == null) { + return new BinaryFileData(blob.getHash(), blob.getByteLength()) + } + return new LazyStringFileData(blob.getHash(), blob.getStringLength()) + } + + toRaw() { + throw new Error('FileData: toRaw not implemented') + } + + /** @see File#getHash */ + getHash() { + return null + } + + /** @see File#getContent */ + getContent() { + return null + } + + /** @see File#isEditable */ + isEditable() { + return null + } + + /** @see File#getByteLength */ + getByteLength() { + return null + } + + /** @see File#getStringLength */ + getStringLength() { + return null + } + + /** @see File#edit */ + edit(textOperation) { + throw new Error('edit not implemented for ' + JSON.stringify(this)) + } + + /** + * @function + * @param {BlobStore} blobStore + * @return {BPromise} + * @abstract + * @see FileData#load + */ + toEager(blobStore) { + return BPromise.reject( + new Error('toEager not implemented for ' + JSON.stringify(this)) + ) + } + + /** + * @function + * @param {BlobStore} blobStore + * @return {BPromise} + * @abstract + * @see FileData#load + */ + toLazy(blobStore) { + return BPromise.reject( + new Error('toLazy not implemented for ' + JSON.stringify(this)) + ) + } + + /** + * @function + * @param {BlobStore} blobStore + * @return {BPromise} + * @abstract + * @see FileData#load + */ + toHollow(blobStore) { + return BPromise.reject( + new Error('toHollow not implemented for ' + JSON.stringify(this)) + ) + } + + /** + * @see File#load + * @param {string} kind + * @param {BlobStore} blobStore + * @return {BPromise} + */ + load(kind, blobStore) { + if (kind === 'eager') return this.toEager(blobStore) + if (kind === 'lazy') return this.toLazy(blobStore) + if (kind === 'hollow') return this.toHollow(blobStore) + throw new Error('bad file data load kind: ' + kind) + } + + /** + * @see File#store + * @function + * @param {BlobStore} blobStore + * @return {BPromise} a raw HashFile + * @abstract + */ + store(blobStore) { + return BPromise.reject( + new Error('store not implemented for ' + JSON.stringify(this)) + ) + } +} + +module.exports = FileData + +BinaryFileData = require('./binary_file_data') +HashFileData = require('./hash_file_data') +HollowBinaryFileData = require('./hollow_binary_file_data') +HollowStringFileData = require('./hollow_string_file_data') +LazyStringFileData = require('./lazy_string_file_data') +StringFileData = require('./string_file_data') diff --git a/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js new file mode 100644 index 0000000000..66e29dcd27 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js @@ -0,0 +1,137 @@ +'use strict' + +const _ = require('lodash') +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const Blob = require('../blob') +const FileData = require('./') +const EagerStringFileData = require('./string_file_data') +const TextOperation = require('../operation/text_operation') + +class LazyStringFileData extends FileData { + /** + * @constructor + * @param {string} hash + * @param {number} stringLength + * @param {Array.} [textOperations] + * @see FileData + */ + constructor(hash, stringLength, textOperations) { + super() + assert.match(hash, Blob.HEX_HASH_RX) + assert.greaterOrEqual(stringLength, 0) + assert.maybe.array.of.instance(textOperations, TextOperation) + + this.hash = hash + this.stringLength = stringLength + this.textOperations = textOperations || [] + } + + static fromRaw(raw) { + return new LazyStringFileData( + raw.hash, + raw.stringLength, + raw.textOperations && _.map(raw.textOperations, TextOperation.fromJSON) + ) + } + + /** @inheritdoc */ + toRaw() { + const raw = { hash: this.hash, stringLength: this.stringLength } + if (this.textOperations.length) { + raw.textOperations = _.map(this.textOperations, function (textOperation) { + return textOperation.toJSON() + }) + } + return raw + } + + /** @inheritdoc */ + getHash() { + if (this.textOperations.length) return null + return this.hash + } + + /** @inheritdoc */ + isEditable() { + return true + } + + /** + * For project quota checking, we approximate the byte length by the UTF-8 + * length for hollow files. This isn't strictly speaking correct; it is an + * underestimate of byte length. + * + * @return {number} + */ + getByteLength() { + return this.stringLength + } + + /** @inheritdoc */ + getStringLength() { + return this.stringLength + } + + /** + * Get the cached text operations that are to be applied to this file to get + * from the content with its last known hash to its latest content. + * + * @return {Array.} + */ + getTextOperations() { + return this.textOperations + } + + /** @inheritdoc */ + toEager(blobStore) { + return blobStore.getString(this.hash).then(content => { + return new EagerStringFileData( + computeContent(this.textOperations, content) + ) + }) + } + + /** @inheritdoc */ + toLazy() { + return BPromise.resolve(this) + } + + /** @inheritdoc */ + toHollow() { + return BPromise.try(() => FileData.createHollow(null, this.stringLength)) + } + + /** @inheritdoc */ + edit(textOperation) { + this.stringLength = textOperation.applyToLength(this.stringLength) + this.textOperations.push(textOperation) + } + + /** @inheritdoc */ + store(blobStore) { + if (this.textOperations.length === 0) + return BPromise.resolve({ hash: this.hash }) + return blobStore + .getString(this.hash) + .then(content => { + return blobStore.putString(computeContent(this.textOperations, content)) + }) + .then(blob => { + this.hash = blob.getHash() + this.stringLength = blob.getStringLength() + this.textOperations.length = 0 + return { hash: this.hash } + }) + } +} + +function computeContent(textOperations, initialFile) { + function applyTextOperation(content, textOperation) { + return textOperation.apply(content) + } + return _.reduce(textOperations, applyTextOperation, initialFile) +} + +module.exports = LazyStringFileData diff --git a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js new file mode 100644 index 0000000000..b0977dafa9 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js @@ -0,0 +1,80 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const FileData = require('./') + +/** + * @typedef {import("../types").StringFileRawData} StringFileRawData + */ + +class StringFileData extends FileData { + /** + * @constructor + * @param {string} content + */ + constructor(content) { + super() + assert.string(content) + this.content = content + } + + static fromRaw(raw) { + return new StringFileData(raw.content) + } + + /** + * @inheritdoc + * @returns {StringFileRawData} + */ + toRaw() { + return { content: this.content } + } + + /** @inheritdoc */ + isEditable() { + return true + } + + /** @inheritdoc */ + getContent() { + return this.content + } + + /** @inheritdoc */ + getByteLength() { + return Buffer.byteLength(this.content) + } + + /** @inheritdoc */ + getStringLength() { + return this.content.length + } + + /** @inheritdoc */ + edit(textOperation) { + this.content = textOperation.apply(this.content) + } + + /** @inheritdoc */ + toEager() { + return BPromise.resolve(this) + } + + /** @inheritdoc */ + toHollow() { + return BPromise.try(() => + FileData.createHollow(this.getByteLength(), this.getStringLength()) + ) + } + + /** @inheritdoc */ + store(blobStore) { + return blobStore.putString(this.content).then(function (blob) { + return { hash: blob.getHash() } + }) + } +} + +module.exports = StringFileData diff --git a/libraries/overleaf-editor-core/lib/file_map.js b/libraries/overleaf-editor-core/lib/file_map.js new file mode 100644 index 0000000000..e4c8de44ba --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_map.js @@ -0,0 +1,317 @@ +'use strict' + +const BPromise = require('bluebird') +const _ = require('lodash') + +const assert = require('check-types').assert +const OError = require('@overleaf/o-error') + +const File = require('./file') +const safePathname = require('./safe_pathname') + +/** + * A set of {@link File}s. Several properties are enforced on the pathnames: + * + * 1. File names and paths are case sensitive and can differ by case alone. This + * is consistent with most Linux file systems, but it is not consistent with + * Windows or OS X. Ideally, we would be case-preserving and case insensitive, + * like they are. And we used to be, but it caused too many incompatibilities + * with the old system, which was case sensitive. See + * https://github.com/overleaf/overleaf-ot-prototype/blob/ + * 19ed046c09f5a4d14fa12b3ea813ce0d977af88a/editor/core/lib/file_map.js + * for an implementation of this map with those properties. + * + * 2. Uniqueness: No two pathnames are the same. + * + * 3. No type conflicts: A pathname cannot refer to both a file and a directory + * within the same snapshot. That is, you can't have pathnames `a` and `a/b` in + * the same file map; {@see FileMap#wouldConflict}. + * + * @param {Object.} files + */ +class FileMap { + constructor(files) { + // create bare object for use as Map + // http://ryanmorr.com/true-hash-maps-in-javascript/ + this.files = Object.create(null) + _.assign(this.files, files) + checkPathnamesAreUnique(this.files) + checkPathnamesDoNotConflict(this) + } + + static fromRaw(raw) { + assert.object(raw, 'bad raw files') + return new FileMap(_.mapValues(raw, File.fromRaw)) + } + + /** + * Convert to raw object for serialization. + * + * @return {Object} + */ + toRaw() { + function fileToRaw(file) { + return file.toRaw() + } + return _.mapValues(this.files, fileToRaw) + } + + /** + * Create the given file. + * + * @param {string} pathname + * @param {File} file + */ + addFile(pathname, file) { + checkPathname(pathname) + assert.object(file, 'bad file') + checkNewPathnameDoesNotConflict(this, pathname) + addFile(this.files, pathname, file) + } + + /** + * Remove the given file. + * + * @param {string} pathname + */ + removeFile(pathname) { + checkPathname(pathname) + + const key = findPathnameKey(this.files, pathname) + if (!key) { + throw new FileMap.FileNotFoundError(pathname) + } + delete this.files[key] + } + + /** + * Move or remove a file. If the origin file does not exist, or if the old + * and new paths are identical, this has no effect. + * + * @param {string} pathname + * @param {string} newPathname if a blank string, {@link FileMap#removeFile} + */ + moveFile(pathname, newPathname) { + if (pathname === newPathname) return + if (newPathname === '') return this.removeFile(pathname) + checkPathname(pathname) + checkPathname(newPathname) + checkNewPathnameDoesNotConflict(this, newPathname, pathname) + + const key = findPathnameKey(this.files, pathname) + if (!key) { + throw new FileMap.FileNotFoundError(pathname) + } + const file = this.files[key] + delete this.files[key] + + addFile(this.files, newPathname, file) + } + + /** + * The number of files in the file map. + * + * @return {number} + */ + countFiles() { + return _.size(this.files) + } + + /** + * Get a file by its pathname. + * + * @param {string} pathname + * @return {File | null | undefined} + */ + getFile(pathname) { + const key = findPathnameKey(this.files, pathname) + return key && this.files[key] + } + + /** + * Whether the given pathname conflicts with any file in the map. + * + * Paths conflict in type if one path is a strict prefix of the other path. For + * example, 'a/b' conflicts with 'a', because in the former case 'a' is a + * folder, but in the latter case it is a file. Similarly, the pathname 'a/b/c' + * conflicts with 'a' and 'a/b', but it does not conflict with 'a/b/c', 'a/x', + * or 'a/b/x'. (In our case, identical paths don't conflict, because AddFile + * and MoveFile overwrite existing files.) + * + * @param {string} pathname + * @param {string} [ignoredPathname] pretend this pathname does not exist + */ + wouldConflict(pathname, ignoredPathname) { + checkPathname(pathname) + assert.maybe.string(ignoredPathname) + const pathnames = this.getPathnames() + const dirname = pathname + '/' + // Check the filemap to see whether the supplied pathname is a + // parent of any entry, or any entry is a parent of the pathname. + for (let i = 0; i < pathnames.length; i++) { + // First check if pathname is a strict prefix of pathnames[i] (and that + // pathnames[i] is not ignored) + if ( + pathnames[i].startsWith(dirname) && + !pathnamesEqual(pathnames[i], ignoredPathname) + ) { + return true + } + // Now make the reverse check, whether pathnames[i] is a strict prefix of + // pathname. To avoid expensive string concatenation on each pathname we + // first perform a partial check with a.startsWith(b), and then do the + // full check for a subsequent '/' if this passes. This saves about 25% + // of the runtime. Again only return a conflict if pathnames[i] is not + // ignored. + if ( + pathname.startsWith(pathnames[i]) && + pathname.length > pathnames[i].length && + pathname[pathnames[i].length] === '/' && + !pathnamesEqual(pathnames[i], ignoredPathname) + ) { + return true + } + } + // No conflicts - after excluding ignoredPathname, there were no entries + // which were a strict prefix of pathname, and pathname was not a strict + // prefix of any entry. + return false + } + + /** @see Snapshot#getFilePathnames */ + getPathnames() { + return _.keys(this.files) + } + + /** + * Map the files in this map to new values. + * @param {function} iteratee + * @return {Object} + */ + map(iteratee) { + return _.mapValues(this.files, iteratee) + } + + /** + * Map the files in this map to new values asynchronously, with an optional + * limit on concurrency. + * @param {function} iteratee like for _.mapValues + * @param {number} [concurrency] as for BPromise.map + * @return {Object} + */ + mapAsync(iteratee, concurrency) { + assert.maybe.number(concurrency, 'bad concurrency') + + const pathnames = this.getPathnames() + return BPromise.map( + pathnames, + file => { + return iteratee(this.getFile(file), file, pathnames) + }, + { concurrency: concurrency || 1 } + ).then(files => { + return _.zipObject(pathnames, files) + }) + } +} + +class PathnameError extends OError {} +FileMap.PathnameError = PathnameError + +class NonUniquePathnameError extends PathnameError { + constructor(pathnames) { + super('pathnames are not unique: ' + pathnames, { pathnames }) + this.pathnames = pathnames + } +} +FileMap.NonUniquePathnameError = NonUniquePathnameError + +class BadPathnameError extends PathnameError { + constructor(pathname) { + super(pathname + ' is not a valid pathname', { pathname }) + this.pathname = pathname + } +} +FileMap.BadPathnameError = BadPathnameError + +class PathnameConflictError extends PathnameError { + constructor(pathname) { + super(`pathname '${pathname}' conflicts with another file`, { pathname }) + this.pathname = pathname + } +} +FileMap.PathnameConflictError = PathnameConflictError + +class FileNotFoundError extends PathnameError { + constructor(pathname) { + super(`file ${pathname} does not exist`, { pathname }) + this.pathname = pathname + } +} +FileMap.FileNotFoundError = FileNotFoundError + +function pathnamesEqual(pathname0, pathname1) { + return pathname0 === pathname1 +} + +function pathnamesAreUnique(files) { + const keys = _.keys(files) + return _.uniqWith(keys, pathnamesEqual).length === keys.length +} + +function checkPathnamesAreUnique(files) { + if (pathnamesAreUnique(files)) return + throw new FileMap.NonUniquePathnameError(_.keys(files)) +} + +function checkPathname(pathname) { + assert.nonEmptyString(pathname, 'bad pathname') + if (safePathname.isClean(pathname)) return + throw new FileMap.BadPathnameError(pathname) +} + +function checkNewPathnameDoesNotConflict(fileMap, pathname, ignoredPathname) { + if (fileMap.wouldConflict(pathname, ignoredPathname)) { + throw new FileMap.PathnameConflictError(pathname) + } +} + +function checkPathnamesDoNotConflict(fileMap) { + const pathnames = fileMap.getPathnames() + // check pathnames for validity first + pathnames.forEach(checkPathname) + // convert pathnames to candidate directory names + const dirnames = [] + for (let i = 0; i < pathnames.length; i++) { + dirnames[i] = pathnames[i] + '/' + } + // sort in lexical order and check if one directory contains another + dirnames.sort() + for (let i = 0; i < dirnames.length - 1; i++) { + if (dirnames[i + 1].startsWith(dirnames[i])) { + // strip trailing slash to get original pathname + const conflictPathname = dirnames[i + 1].substr(0, -1) + throw new FileMap.PathnameConflictError(conflictPathname) + } + } +} + +// +// This function is somewhat vestigial: it was used when this map used +// case-insensitive pathname comparison. We could probably simplify some of the +// logic in the callers, but in the hope that we will one day return to +// case-insensitive semantics, we've just left things as-is for now. +// +function findPathnameKey(files, pathname) { + // we can check for the key without worrying about properties + // in the prototype because we are now using a bare object/ + if (pathname in files) return pathname +} + +function addFile(files, pathname, file) { + const key = findPathnameKey(files, pathname) + if (key) delete files[key] + files[pathname] = file +} + +module.exports = FileMap diff --git a/libraries/overleaf-editor-core/lib/history.js b/libraries/overleaf-editor-core/lib/history.js new file mode 100644 index 0000000000..2b15e0103e --- /dev/null +++ b/libraries/overleaf-editor-core/lib/history.js @@ -0,0 +1,125 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const Change = require('./change') +const Snapshot = require('./snapshot') + +/** + * @typedef {import("./types").BlobStore} BlobStore + */ + +/** + * @constructor + * @param {Snapshot} snapshot + * @param {Array.} changes + * + * @classdesc + * A History is a {@link Snapshot} and a sequence of {@link Change}s that can + * be applied to produce a new snapshot. + */ +function History(snapshot, changes) { + assert.instance(snapshot, Snapshot, 'bad snapshot') + assert.maybe.array.of.instance(changes, Change, 'bad changes') + + this.snapshot = snapshot + this.changes = changes || [] +} + +History.fromRaw = function historyFromRaw(raw) { + return new History( + Snapshot.fromRaw(raw.snapshot), + raw.changes.map(Change.fromRaw) + ) +} + +History.prototype.toRaw = function historyToRaw() { + function changeToRaw(change) { + return change.toRaw() + } + return { + snapshot: this.snapshot.toRaw(), + changes: this.changes.map(changeToRaw), + } +} + +History.prototype.getSnapshot = function () { + return this.snapshot +} +History.prototype.getChanges = function () { + return this.changes +} + +History.prototype.countChanges = function historyCountChanges() { + return this.changes.length +} + +/** + * Add changes to this history. + * + * @param {Array.} changes + */ +History.prototype.pushChanges = function historyPushChanges(changes) { + this.changes.push.apply(this.changes, changes) +} + +/** + * If this History references blob hashes, either in the Snapshot or the + * Changes, add them to the given set. + * + * @param {Set.} blobHashes + */ +History.prototype.findBlobHashes = function historyFindBlobHashes(blobHashes) { + function findChangeBlobHashes(change) { + change.findBlobHashes(blobHashes) + } + this.snapshot.findBlobHashes(blobHashes) + this.changes.forEach(findChangeBlobHashes) +} + +/** + * If this History contains any File objects, load them. + * + * @param {string} kind see {File#load} + * @param {BlobStore} blobStore + * @return {Promise} + */ +History.prototype.loadFiles = function historyLoadFiles(kind, blobStore) { + function loadChangeFiles(change) { + return change.loadFiles(kind, blobStore) + } + return BPromise.join( + this.snapshot.loadFiles(kind, blobStore), + BPromise.each(this.changes, loadChangeFiles) + ) +} + +/** + * Return a version of this history that is suitable for long term storage. + * This requires that we store the content of file objects in the provided + * blobStore. + * + * @param {BlobStore} blobStore + * @param {number} [concurrency] applies separately to files, changes and + * operations + * @return {Promise.} + */ +History.prototype.store = function historyStoreFunc(blobStore, concurrency) { + assert.maybe.number(concurrency, 'bad concurrency') + + function storeChange(change) { + return change.store(blobStore, concurrency) + } + return BPromise.join( + this.snapshot.store(blobStore, concurrency), + BPromise.map(this.changes, storeChange, { concurrency: concurrency || 1 }) + ).then(([rawSnapshot, rawChanges]) => { + return { + snapshot: rawSnapshot, + changes: rawChanges, + } + }) +} + +module.exports = History diff --git a/libraries/overleaf-editor-core/lib/label.js b/libraries/overleaf-editor-core/lib/label.js new file mode 100644 index 0000000000..df516483b6 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/label.js @@ -0,0 +1,82 @@ +'use strict' + +const assert = require('check-types').assert + +/** + * @constructor + * @param {string} text + * @classdesc + * A user-configurable label that can be attached to a specific change. Labels + * are not versioned, and they are not stored alongside the Changes in Chunks. + * They are instead intended to provide external markers into the history of the + * project. + */ +function Label(text, authorId, timestamp, version) { + assert.string(text, 'bad text') + assert.maybe.integer(authorId, 'bad author id') + assert.date(timestamp, 'bad timestamp') + assert.integer(version, 'bad version') + + this.text = text + this.authorId = authorId + this.timestamp = timestamp + this.version = version +} + +/** + * Create a Label from its raw form. + * + * @param {Object} raw + * @return {Label} + */ +Label.fromRaw = function labelFromRaw(raw) { + return new Label(raw.text, raw.authorId, new Date(raw.timestamp), raw.version) +} + +/** + * Convert the Label to raw form for transmission. + * + * @return {Object} + */ +Label.prototype.toRaw = function labelToRaw() { + return { + text: this.text, + authorId: this.authorId, + timestamp: this.timestamp.toISOString(), + version: this.version, + } +} + +/** + * @return {string} + */ +Label.prototype.getText = function () { + return this.text +} + +/** + * The ID of the author, if any. Note that we now require all saved versions to + * have an author, but this was not always the case, so we have to allow nulls + * here for historical reasons. + * + * @return {number | null | undefined} + */ +Label.prototype.getAuthorId = function () { + return this.authorId +} + +/** + * @return {Date} + */ +Label.prototype.getTimestamp = function () { + return this.timestamp +} + +/** + * @return {number | undefined} + */ +Label.prototype.getVersion = function () { + return this.version +} + +module.exports = Label diff --git a/libraries/overleaf-editor-core/lib/operation/add_file_operation.js b/libraries/overleaf-editor-core/lib/operation/add_file_operation.js new file mode 100644 index 0000000000..8560c7f42e --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/add_file_operation.js @@ -0,0 +1,81 @@ +'use strict' + +const assert = require('check-types').assert + +const File = require('../file') +const Operation = require('./') + +/** + * @classdesc + * Adds a new file to a project. + */ +class AddFileOperation extends Operation { + /** + * @constructor + * @param {string} pathname + * @param {File} file + */ + constructor(pathname, file) { + super() + assert.string(pathname, 'bad pathname') + assert.object(file, 'bad file') + + this.pathname = pathname + this.file = file + } + + /** + * @return {String} + */ + getPathname() { + return this.pathname + } + + /** + * TODO + * @param {Object} raw + * @return {AddFileOperation} + */ + static fromRaw(raw) { + return new AddFileOperation(raw.pathname, File.fromRaw(raw.file)) + } + + /** + * @inheritdoc + */ + toRaw() { + return { pathname: this.pathname, file: this.file.toRaw() } + } + + /** + * @inheritdoc + */ + getFile() { + return this.file + } + + /** @inheritdoc */ + findBlobHashes(blobHashes) { + const hash = this.file.getHash() + if (hash) blobHashes.add(hash) + } + + /** @inheritdoc */ + loadFiles(kind, blobStore) { + return this.file.load(kind, blobStore) + } + + store(blobStore) { + return this.file.store(blobStore).then(rawFile => { + return { pathname: this.pathname, file: rawFile } + }) + } + + /** + * @inheritdoc + */ + applyTo(snapshot) { + snapshot.addFile(this.pathname, this.file.clone()) + } +} +module.exports = AddFileOperation diff --git a/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js b/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js new file mode 100644 index 0000000000..e5c88bcdb0 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js @@ -0,0 +1,93 @@ +'use strict' + +const Operation = require('./') +const TextOperation = require('./text_operation') + +/** + * @classdesc + * Edit a file in place. It is a wrapper around a single TextOperation. + */ +class EditFileOperation extends Operation { + /** + * @constructor + * @param {string} pathname + * @param {TextOperation} textOperation + */ + constructor(pathname, textOperation) { + super() + this.pathname = pathname + this.textOperation = textOperation + } + + /** + * @inheritdoc + */ + toRaw() { + return { + pathname: this.pathname, + textOperation: this.textOperation.toJSON(), + } + } + + /** + * Deserialize an EditFileOperation. + * + * @param {Object} raw + * @return {EditFileOperation} + */ + static fromRaw(raw) { + return new EditFileOperation( + raw.pathname, + TextOperation.fromJSON(raw.textOperation) + ) + } + + getPathname() { + return this.pathname + } + + getTextOperation() { + return this.textOperation + } + + /** + * @inheritdoc + */ + applyTo(snapshot) { + snapshot.editFile(this.pathname, this.textOperation) + } + + /** + * @inheritdoc + */ + canBeComposedWithForUndo(other) { + return ( + this.canBeComposedWith(other) && + this.textOperation.canBeComposedWithForUndo(other.textOperation) + ) + } + + /** + * @inheritdoc + */ + canBeComposedWith(other) { + // Ensure that other operation is an edit file operation + if (!(other instanceof EditFileOperation)) return false + // Ensure that both operations are editing the same file + if (this.getPathname() !== other.getPathname()) return false + + return this.textOperation.canBeComposedWith(other.textOperation) + } + + /** + * @inheritdoc + */ + compose(other) { + return new EditFileOperation( + this.pathname, + this.textOperation.compose(other.textOperation) + ) + } +} + +module.exports = EditFileOperation diff --git a/libraries/overleaf-editor-core/lib/operation/index.js b/libraries/overleaf-editor-core/lib/operation/index.js new file mode 100644 index 0000000000..a58c1345c0 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/index.js @@ -0,0 +1,463 @@ +'use strict' + +const _ = require('lodash') +const assert = require('check-types').assert +const BPromise = require('bluebird') + +const TextOperation = require('./text_operation') + +// Dependencies are loaded at the bottom of the file to mitigate circular +// dependency +let NoOperation = null +let AddFileOperation = null +let MoveFileOperation = null +let EditFileOperation = null +let SetFileMetadataOperation = null + +/** + * @typedef {import("../types").BlobStore} BlobStore + * @typedef {import("../snapshot")} Snapshot + */ + +/** + * @classdesc + * An `Operation` changes a `Snapshot` when it is applied. See the + * {@tutorial OT} tutorial for background. + */ +class Operation { + /** + * Deserialize an Operation. + * + * @param {Object} raw + * @return {Operation} one of the subclasses + */ + static fromRaw(raw) { + if (Object.prototype.hasOwnProperty.call(raw, 'file')) { + return AddFileOperation.fromRaw(raw) + } + if (Object.prototype.hasOwnProperty.call(raw, 'textOperation')) { + return EditFileOperation.fromRaw(raw) + } + if (Object.prototype.hasOwnProperty.call(raw, 'newPathname')) { + return new MoveFileOperation(raw.pathname, raw.newPathname) + } + if (Object.prototype.hasOwnProperty.call(raw, 'metadata')) { + return new SetFileMetadataOperation(raw.pathname, raw.metadata) + } + if (_.isEmpty(raw)) { + return new NoOperation() + } + throw new Error('invalid raw operation ' + JSON.stringify(raw)) + } + + /** + * Serialize an Operation. + * + * @return {Object} + */ + toRaw() { + return {} + } + + /** + * Whether this operation does nothing when applied. + * + * @return {Boolean} + */ + isNoOp() { + return false + } + + /** + * If this Operation references blob hashes, add them to the given Set. + * + * @param {Set.} blobHashes + */ + findBlobHashes(blobHashes) {} + + /** + * If this operation references any files, load the files. + * + * @param {string} kind see {File#load} + * @param {BlobStore} blobStore + * @return {Promise} + */ + loadFiles(kind, blobStore) { + return BPromise.resolve() + } + + /** + * Return a version of this operation that is suitable for long term storage. + * In most cases, we just need to convert the operation to raw form, but if + * the operation involves File objects, we may need to store their content. + * + * @param {BlobStore} blobStore + * @return {Promise.} + */ + store(blobStore) { + return BPromise.try(() => this.toRaw()) + } + + /** + * Apply this Operation to a snapshot. + * + * The snapshot is modified in place. + * + * @param {Snapshot} snapshot + */ + applyTo(snapshot) { + assert.object(snapshot, 'bad snapshot') + } + + /** + * Whether this operation can be composed with another operation to produce a + * single operation of the same type as this one, while keeping the composed + * operation small and logical enough to be used in the undo stack. + * + * @param {Operation} other + * @return {Boolean} + */ + canBeComposedWithForUndo(other) { + return false + } + + /** + * Whether this operation can be composed with another operation to produce a + * single operation of the same type as this one. + * + * TODO Moves can be composed. For example, if you rename a to b and then decide + * shortly after that actually you want to call it c, we could compose the two + * to get a -> c). Edits can also be composed --- see rules in TextOperation. + * We also need to consider the Change --- we will need to consider both time + * and author(s) when composing changes. I guess that AddFile can also be + * composed in some cases --- if you upload a file and then decide it was the + * wrong one and upload a new one, we could drop the one in the middle, but + * that seems like a pretty rare case. + * + * @param {Operation} other + * @return {Boolean} + */ + canBeComposedWith(other) { + return false + } + + /** + * Compose this operation with another operation to produce a single operation + * of the same type as this one. + * + * @param {Operation} other + * @return {Operation} + */ + compose(other) { + throw new Error('not implemented') + } + + /** + * Transform takes two operations A and B that happened concurrently and + * produces two operations A' and B' (in an array) such that + * `apply(apply(S, A), B') = apply(apply(S, B), A')`. + * + * That is, if one client applies A and then B', they get the same result as + * another client who applies B and then A'. + * + * @param {Operation} a + * @param {Operation} b + * @return {Operation[]} operations `[a', b']` + */ + static transform(a, b) { + if (a.isNoOp() || b.isNoOp()) return [b, a] + + function transpose(transformer) { + return transformer(b, a).reverse() + } + + const bIsAddFile = b instanceof AddFileOperation + const bIsEditFile = b instanceof EditFileOperation + const bIsMoveFile = b instanceof MoveFileOperation + const bIsSetFileMetadata = b instanceof SetFileMetadataOperation + + if (a instanceof AddFileOperation) { + if (bIsAddFile) return transformAddFileAddFile(a, b) + if (bIsMoveFile) return transformAddFileMoveFile(a, b) + if (bIsEditFile) return transformAddFileEditFile(a, b) + if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b) + throw new Error('bad op b') + } + if (a instanceof MoveFileOperation) { + if (bIsAddFile) return transpose(transformAddFileMoveFile) + if (bIsMoveFile) return transformMoveFileMoveFile(a, b) + if (bIsEditFile) return transformMoveFileEditFile(a, b) + if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b) + throw new Error('bad op b') + } + if (a instanceof EditFileOperation) { + if (bIsAddFile) return transpose(transformAddFileEditFile) + if (bIsMoveFile) return transpose(transformMoveFileEditFile) + if (bIsEditFile) return transformEditFileEditFile(a, b) + if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b) + throw new Error('bad op b') + } + if (a instanceof SetFileMetadataOperation) { + if (bIsAddFile) return transpose(transformAddFileSetFileMetadata) + if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata) + if (bIsEditFile) return transpose(transformEditFileSetFileMetadata) + if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b) + throw new Error('bad op b') + } + throw new Error('bad op a') + } + + /** + * Transform each operation in `a` by each operation in `b` and save the primed + * operations in place. + * + * @param {Array.} as - modified in place + * @param {Array.} bs - modified in place + */ + static transformMultiple(as, bs) { + for (let i = 0; i < as.length; ++i) { + for (let j = 0; j < bs.length; ++j) { + const primes = Operation.transform(as[i], bs[j]) + as[i] = primes[0] + bs[j] = primes[1] + } + } + } + + static addFile(pathname, file) { + return new AddFileOperation(pathname, file) + } + + static editFile(pathname, textOperation) { + return new EditFileOperation(pathname, textOperation) + } + + static moveFile(pathname, newPathname) { + return new MoveFileOperation(pathname, newPathname) + } + + static removeFile(pathname) { + return new MoveFileOperation(pathname, '') + } + + static setFileMetadata(pathname, metadata) { + return new SetFileMetadataOperation(pathname, metadata) + } +} + +// +// Transform +// +// The way to read these transform functions is that +// 1. return_value[0] is the op to be applied after arguments[1], and +// 2. return_value[1] is the op to be applied after arguments[0], +// in order to arrive at the same project state. +// + +function transformAddFileAddFile(add1, add2) { + if (add1.getPathname() === add2.getPathname()) { + return [Operation.NO_OP, add2] // add2 wins + } + + return [add1, add2] +} + +function transformAddFileMoveFile(add, move) { + function relocateAddFile() { + return new AddFileOperation(move.getNewPathname(), add.getFile().clone()) + } + + if (add.getPathname() === move.getPathname()) { + if (move.isRemoveFile()) { + return [add, Operation.NO_OP] + } + return [ + relocateAddFile(), + new MoveFileOperation(add.getPathname(), move.getNewPathname()), + ] + } + + if (add.getPathname() === move.getNewPathname()) { + return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')] + } + + return [add, move] +} + +function transformAddFileEditFile(add, edit) { + if (add.getPathname() === edit.getPathname()) { + return [add, Operation.NO_OP] // the add wins + } + + return [add, edit] +} + +function transformAddFileSetFileMetadata(add, set) { + if (add.getPathname() === set.getPathname()) { + const newFile = add.getFile().clone() + newFile.setMetadata(set.getMetadata()) + return [new AddFileOperation(add.getPathname(), newFile), set] + } + + return [add, set] +} + +// +// This is one of the trickier ones. There are 15 possible equivalence +// relationships between our four variables: +// +// path1, newPath1, path2, newPath2 --- "same move" (all equal) +// +// path1, newPath1, path2 | newPath2 --- "no-ops" (1) +// path1, newPath1, newPath2 | path2 --- "no-ops" (1) +// path1, path2, newPath2 | newPath1 --- "no-ops" (2) +// newPath1, path2, newPath2 | path1 --- "no-ops" (2) +// +// path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2) +// path1, path2 | newPath1, newPath2 --- "same move" +// path1, newPath2 | newPath1, path2 --- "opposite moves" +// +// path1, newPath1 | path2 | newPath2 --- "no-ops" (1) +// path1, path2 | newPath1 | newPath2 --- "divergent moves" +// path1, newPath2 | newPath1 | path2 --- "transitive move" +// newPath1, path2 | path1 | newPath2 --- "transitive move" +// newPath1, newPath2 | path1 | path2 --- "convergent move" +// path2, newPath2 | path1 | newPath1 --- "no-ops" (2) +// +// path1 | newPath1 | path2 | newPath2 --- "no conflict" +// +function transformMoveFileMoveFile(move1, move2) { + const path1 = move1.getPathname() + const path2 = move2.getPathname() + const newPath1 = move1.getNewPathname() + const newPath2 = move2.getNewPathname() + + // the same move + if (path1 === path2 && newPath1 === newPath2) { + return [Operation.NO_OP, Operation.NO_OP] + } + + // no-ops + if (path1 === newPath1 && path2 === newPath2) { + return [Operation.NO_OP, Operation.NO_OP] + } + if (path1 === newPath1) { + return [Operation.NO_OP, move2] + } + if (path2 === newPath2) { + return [move1, Operation.NO_OP] + } + + // opposite moves (foo -> bar, bar -> foo) + if (path1 === newPath2 && path2 === newPath1) { + // We can't handle this very well: if we wanted move2 (say) to win, move2' + // would have to be addFile(foo) with the content of bar, but we don't have + // the content of bar available here. So, we just destroy both files. + return [Operation.removeFile(path1), Operation.removeFile(path2)] + } + + // divergent moves (foo -> bar, foo -> baz); convention: move2 wins + if (path1 === path2 && newPath1 !== newPath2) { + return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)] + } + + // convergent move (foo -> baz, bar -> baz); convention: move2 wins + if (newPath1 === newPath2 && path1 !== path2) { + return [Operation.removeFile(path1), move2] + } + + // transitive move: + // 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or + // 1: foo -> bar, 2: bar -> baz (result: foo -> baz) + if (path1 === newPath2 && newPath1 !== path2) { + return [ + Operation.moveFile(newPath2, newPath1), + Operation.moveFile(path2, newPath1), + ] + } + if (newPath1 === path2 && path1 !== newPath2) { + return [ + Operation.moveFile(path1, newPath2), + Operation.moveFile(newPath1, newPath2), + ] + } + + // no conflict + return [move1, move2] +} + +function transformMoveFileEditFile(move, edit) { + if (move.getPathname() === edit.getPathname()) { + if (move.isRemoveFile()) { + // let the remove win + return [move, Operation.NO_OP] + } + return [ + move, + Operation.editFile(move.getNewPathname(), edit.getTextOperation()), + ] + } + + if (move.getNewPathname() === edit.getPathname()) { + // let the move win + return [move, Operation.NO_OP] + } + + return [move, edit] +} + +function transformMoveFileSetFileMetadata(move, set) { + if (move.getPathname() === set.getPathname()) { + return [ + move, + Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()), + ] + } + // A: mv foo -> bar + // B: set bar.x + // + // A': mv foo -> bar + // B': nothing + if (move.getNewPathname() === set.getPathname()) { + return [move, Operation.NO_OP] // let the move win + } + return [move, set] +} + +function transformEditFileEditFile(edit1, edit2) { + if (edit1.getPathname() === edit2.getPathname()) { + const primeTextOps = TextOperation.transform( + edit1.getTextOperation(), + edit2.getTextOperation() + ) + return [ + Operation.editFile(edit1.getPathname(), primeTextOps[0]), + Operation.editFile(edit2.getPathname(), primeTextOps[1]), + ] + } + + return [edit1, edit2] +} + +function transformEditFileSetFileMetadata(edit, set) { + // There is no conflict. + return [edit, set] +} + +function transformSetFileMetadatas(set1, set2) { + if (set1.getPathname() === set2.getPathname()) { + return [Operation.NO_OP, set2] // set2 wins + } + return [set1, set2] +} + +module.exports = Operation + +// Work around circular import +NoOperation = require('./no_operation') +AddFileOperation = require('./add_file_operation') +MoveFileOperation = require('./move_file_operation') +EditFileOperation = require('./edit_file_operation') +SetFileMetadataOperation = require('./set_file_metadata_operation') + +Operation.NO_OP = new NoOperation() diff --git a/libraries/overleaf-editor-core/lib/operation/move_file_operation.js b/libraries/overleaf-editor-core/lib/operation/move_file_operation.js new file mode 100644 index 0000000000..01552d99aa --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/move_file_operation.js @@ -0,0 +1,54 @@ +'use strict' + +const Operation = require('./') + +/** + * @classdesc + * Moves or removes a file from a project. + */ +class MoveFileOperation extends Operation { + /** + * @param {string} pathname + * @param {string} newPathname + */ + constructor(pathname, newPathname) { + super() + this.pathname = pathname + this.newPathname = newPathname + } + + /** + * @inheritdoc + */ + toRaw() { + return { + pathname: this.pathname, + newPathname: this.newPathname, + } + } + + getPathname() { + return this.pathname + } + + getNewPathname() { + return this.newPathname + } + + /** + * Whether this operation is a MoveFile operation that deletes the file. + * + * @return {boolean} + */ + isRemoveFile() { + return this.getNewPathname() === '' + } + + /** + * @inheritdoc + */ + applyTo(snapshot) { + snapshot.moveFile(this.getPathname(), this.getNewPathname()) + } +} +module.exports = MoveFileOperation diff --git a/libraries/overleaf-editor-core/lib/operation/no_operation.js b/libraries/overleaf-editor-core/lib/operation/no_operation.js new file mode 100644 index 0000000000..a28cea5bdf --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/no_operation.js @@ -0,0 +1,21 @@ +'use strict' + +const Operation = require('./') + +/** + * @classdesc + * An explicit no-operation. + * + * There are several no-ops, such as moving a file to itself, but it's useful + * to have a generic no-op as well. + */ +class NoOperation extends Operation { + /** + * @inheritdoc + */ + isNoOp() { + return true + } +} + +module.exports = NoOperation diff --git a/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js b/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js new file mode 100644 index 0000000000..d689ea9f73 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js @@ -0,0 +1,55 @@ +'use strict' + +const _ = require('lodash') +const assert = require('check-types').assert + +const Operation = require('./') + +/** + * @classdesc + * Moves or removes a file from a project. + */ +class SetFileMetadataOperation extends Operation { + /** + * @constructor + * @param {string} pathname + * @param {Object} metadata + */ + constructor(pathname, metadata) { + super() + assert.string(pathname, 'SetFileMetadataOperation: bad pathname') + assert.object(metadata, 'SetFileMetadataOperation: bad metadata') + + this.pathname = pathname + this.metadata = metadata + } + + /** + * @inheritdoc + */ + toRaw() { + return { + pathname: this.pathname, + metadata: _.cloneDeep(this.metadata), + } + } + + getPathname() { + return this.pathname + } + + getMetadata() { + return this.metadata + } + + /** + * @inheritdoc + */ + applyTo(snapshot) { + const file = snapshot.getFile(this.pathname) + if (!file) return + file.setMetadata(this.metadata) + } +} + +module.exports = SetFileMetadataOperation diff --git a/libraries/overleaf-editor-core/lib/operation/text_operation.js b/libraries/overleaf-editor-core/lib/operation/text_operation.js new file mode 100644 index 0000000000..94ad07895d --- /dev/null +++ b/libraries/overleaf-editor-core/lib/operation/text_operation.js @@ -0,0 +1,682 @@ +/** + * The text operation from OT.js with some minor cosmetic changes. + * + * Specifically, this is based on + * https://github.com/Operational-Transformation/ot.js/ + * blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js + * from 18 Mar 2013. + */ + +'use strict' + +const containsNonBmpChars = require('../util').containsNonBmpChars + +const OError = require('@overleaf/o-error') + +/** + * Create an empty text operation. + * + * @class + */ +function TextOperation() { + // When an operation is applied to an input string, you can think of this as + // if an imaginary cursor runs over the entire string and skips over some + // parts, removes some parts and inserts characters at some positions. These + // actions (skip/remove/insert) are stored as an array in the "ops" property. + this.ops = [] + // An operation's baseLength is the length of every string the operation + // can be applied to. + this.baseLength = 0 + // The targetLength is the length of every string that results from applying + // the operation on a valid input string. + this.targetLength = 0 +} + +/** + * Length of the longest file that we'll attempt to edit, in characters. + * + * @type {number} + */ +TextOperation.MAX_STRING_LENGTH = 2 * Math.pow(1024, 2) + +TextOperation.prototype.equals = function (other) { + if (this.baseLength !== other.baseLength) { + return false + } + if (this.targetLength !== other.targetLength) { + return false + } + if (this.ops.length !== other.ops.length) { + return false + } + for (let i = 0; i < this.ops.length; i++) { + if (this.ops[i] !== other.ops[i]) { + return false + } + } + return true +} + +class UnprocessableError extends OError {} +TextOperation.UnprocessableError = UnprocessableError + +class ApplyError extends UnprocessableError { + constructor(message, operation, operand) { + super(message, { operation, operand }) + this.operation = operation + this.operand = operand + } +} +TextOperation.ApplyError = ApplyError + +class InvalidInsertionError extends UnprocessableError { + constructor(str, operation) { + super('inserted text contains non BMP characters', { str, operation }) + this.str = str + this.operation = operation + } +} +TextOperation.InvalidInsertionError = InvalidInsertionError + +class TooLongError extends UnprocessableError { + constructor(operation, resultLength) { + super(`resulting string would be too long: ${resultLength}`, { + operation, + resultLength, + }) + this.operation = operation + this.resultLength = resultLength + } +} +TextOperation.TooLongError = TooLongError + +// Operation are essentially lists of ops. There are three types of ops: +// +// * Retain ops: Advance the cursor position by a given number of characters. +// Represented by positive ints. +// * Insert ops: Insert a given string at the current cursor position. +// Represented by strings. +// * Remove ops: Remove the next n characters. Represented by negative ints. + +const isRetain = (TextOperation.isRetain = function (op) { + return typeof op === 'number' && op > 0 +}) + +const isInsert = (TextOperation.isInsert = function (op) { + return typeof op === 'string' +}) + +const isRemove = (TextOperation.isRemove = function (op) { + return typeof op === 'number' && op < 0 +}) + +// After an operation is constructed, the user of the library can specify the +// actions of an operation (skip/insert/remove) with these three builder +// methods. They all return the operation for convenient chaining. + +// Skip over a given number of characters. +TextOperation.prototype.retain = function (n) { + if (typeof n !== 'number') { + throw new Error('retain expects an integer') + } + if (n === 0) { + return this + } + this.baseLength += n + this.targetLength += n + if (isRetain(this.ops[this.ops.length - 1])) { + // The last op is a retain op => we can merge them into one op. + this.ops[this.ops.length - 1] += n + } else { + // Create a new op. + this.ops.push(n) + } + return this +} + +// Insert a string at the current position. +TextOperation.prototype.insert = function (str) { + if (typeof str !== 'string') { + throw new Error('insert expects a string') + } + if (containsNonBmpChars(str)) { + throw new TextOperation.InvalidInsertionError(str) + } + if (str === '') { + return this + } + this.targetLength += str.length + const ops = this.ops + if (isInsert(ops[ops.length - 1])) { + // Merge insert op. + ops[ops.length - 1] += str + } else if (isRemove(ops[ops.length - 1])) { + // It doesn't matter when an operation is applied whether the operation + // is remove(3), insert("something") or insert("something"), remove(3). + // Here we enforce that in this case, the insert op always comes first. + // This makes all operations that have the same effect when applied to + // a document of the right length equal in respect to the `equals` method. + if (isInsert(ops[ops.length - 2])) { + ops[ops.length - 2] += str + } else { + ops[ops.length] = ops[ops.length - 1] + ops[ops.length - 2] = str + } + } else { + ops.push(str) + } + return this +} + +// Remove a string at the current position. +TextOperation.prototype.remove = function (n) { + if (typeof n === 'string') { + n = n.length + } + if (typeof n !== 'number') { + throw new Error('remove expects an integer or a string') + } + if (n === 0) { + return this + } + if (n > 0) { + n = -n + } + this.baseLength -= n + if (isRemove(this.ops[this.ops.length - 1])) { + this.ops[this.ops.length - 1] += n + } else { + this.ops.push(n) + } + return this +} + +// Tests whether this operation has no effect. +TextOperation.prototype.isNoop = function () { + return ( + this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])) + ) +} + +// Pretty printing. +TextOperation.prototype.toString = function () { + return this.ops + .map(op => { + if (isRetain(op)) { + return 'retain ' + op + } else if (isInsert(op)) { + return "insert '" + op + "'" + } else { + return 'remove ' + -op + } + }) + .join(', ') +} + +// Converts operation into a JSON value. +TextOperation.prototype.toJSON = function () { + return this.ops +} + +// Converts a plain JS object into an operation and validates it. +TextOperation.fromJSON = function (ops) { + const o = new TextOperation() + for (let i = 0, l = ops.length; i < l; i++) { + const op = ops[i] + if (isRetain(op)) { + o.retain(op) + } else if (isInsert(op)) { + o.insert(op) + } else if (isRemove(op)) { + o.remove(op) + } else { + throw new Error( + 'unknown operation: ' + + JSON.stringify(op) + + ' in ' + + JSON.stringify(ops) + ) + } + } + return o +} + +// Apply an operation to a string, returning a new string. Throws an error if +// there's a mismatch between the input string and the operation. +TextOperation.prototype.apply = function (str) { + const operation = this + if (containsNonBmpChars(str)) { + throw new TextOperation.ApplyError( + 'The string contains non BMP characters.', + operation, + str + ) + } + if (str.length !== operation.baseLength) { + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str + ) + } + + // Build up the result string directly by concatenation (which is actually + // faster than joining arrays because it is optimised in v8). + let result = '' + let strIndex = 0 + const ops = this.ops + for (let i = 0, l = ops.length; i < l; i++) { + const op = ops[i] + if (isRetain(op)) { + if (strIndex + op > str.length) { + throw new TextOperation.ApplyError( + "Operation can't retain more chars than are left in the string.", + operation, + str + ) + } + // Copy skipped part of the old string. + result += str.slice(strIndex, strIndex + op) + strIndex += op + } else if (isInsert(op)) { + if (containsNonBmpChars(op)) { + throw new TextOperation.InvalidInsertionError(str, operation) + } + // Insert string. + result += op + } else { + // remove op + strIndex -= op + } + } + if (strIndex !== str.length) { + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } + + if (result.length > TextOperation.MAX_STRING_LENGTH) { + throw new TextOperation.TooLongError(operation, result.length) + } + return result +} + +/** + * Determine the effect of this operation on the length of the text. + * + * NB: This is an Overleaf addition to the original TextOperation. + * + * @param {number} length of the original string; non-negative + * @return {number} length of the new string; non-negative + */ +TextOperation.prototype.applyToLength = function (length) { + const operation = this + if (length !== operation.baseLength) { + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + length + ) + } + let newLength = 0 + let strIndex = 0 + const ops = this.ops + for (let i = 0, l = ops.length; i < l; i++) { + const op = ops[i] + if (isRetain(op)) { + if (strIndex + op > length) { + throw new TextOperation.ApplyError( + "Operation can't retain more chars than are left in the string.", + operation, + length + ) + } + // Copy skipped part of the old string. + newLength += op + strIndex += op + } else if (isInsert(op)) { + // Insert string. + newLength += op.length + } else { + // remove op + strIndex -= op + } + } + if (strIndex !== length) { + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + length + ) + } + if (newLength > TextOperation.MAX_STRING_LENGTH) { + throw new TextOperation.TooLongError(operation, newLength) + } + return newLength +} + +// Computes the inverse of an operation. The inverse of an operation is the +// operation that reverts the effects of the operation, e.g. when you have an +// operation 'insert("hello "); skip(6);' then the inverse is 'remove("hello "); +// skip(6);'. The inverse should be used for implementing undo. +TextOperation.prototype.invert = function (str) { + let strIndex = 0 + const inverse = new TextOperation() + const ops = this.ops + for (let i = 0, l = ops.length; i < l; i++) { + const op = ops[i] + if (isRetain(op)) { + inverse.retain(op) + strIndex += op + } else if (isInsert(op)) { + inverse.remove(op.length) + } else { + // remove op + inverse.insert(str.slice(strIndex, strIndex - op)) + strIndex -= op + } + } + return inverse +} + +// When you use ctrl-z to undo your latest changes, you expect the program not +// to undo every single keystroke but to undo your last sentence you wrote at +// a stretch or the deletion you did by holding the backspace key down. This +// This can be implemented by composing operations on the undo stack. This +// method can help decide whether two operations should be composed. It +// returns true if the operations are consecutive insert operations or both +// operations delete text at the same position. You may want to include other +// factors like the time since the last change in your decision. +TextOperation.prototype.canBeComposedWithForUndo = function (other) { + if (this.isNoop() || other.isNoop()) { + return true + } + + const startA = getStartIndex(this) + const startB = getStartIndex(other) + const simpleA = getSimpleOp(this) + const simpleB = getSimpleOp(other) + if (!simpleA || !simpleB) { + return false + } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB + } + + if (isRemove(simpleA) && isRemove(simpleB)) { + // there are two possibilities to delete: with backspace and with the + // delete key. + return startB - simpleB === startA || startA === startB + } + + return false +} + +/** + * @inheritdoc + */ +TextOperation.prototype.canBeComposedWith = function (other) { + return this.targetLength === other.baseLength +} + +// Compose merges two consecutive operations into one operation, that +// preserves the changes of both. Or, in other words, for each input string S +// and a pair of consecutive operations A and B, +// apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. +TextOperation.prototype.compose = function (operation2) { + const operation1 = this + if (operation1.targetLength !== operation2.baseLength) { + throw new Error( + 'The base length of the second operation has to be the ' + + 'target length of the first operation' + ) + } + + const operation = new TextOperation() // the combined operation + const ops1 = operation1.ops + const ops2 = operation2.ops // for fast access + let i1 = 0 + let i2 = 0 // current index into ops1 respectively ops2 + let op1 = ops1[i1++] + let op2 = ops2[i2++] // current ops + for (;;) { + // Dispatch on the type of op1 and op2 + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break + } + + if (isRemove(op1)) { + operation.remove(op1) + op1 = ops1[i1++] + continue + } + if (isInsert(op2)) { + operation.insert(op2) + op2 = ops2[i2++] + continue + } + + if (typeof op1 === 'undefined') { + throw new Error( + 'Cannot compose operations: first operation is too short.' + ) + } + if (typeof op2 === 'undefined') { + throw new Error('Cannot compose operations: first operation is too long.') + } + + if (isRetain(op1) && isRetain(op2)) { + if (op1 > op2) { + operation.retain(op2) + op1 = op1 - op2 + op2 = ops2[i2++] + } else if (op1 === op2) { + operation.retain(op1) + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + operation.retain(op1) + op2 = op2 - op1 + op1 = ops1[i1++] + } + } else if (isInsert(op1) && isRemove(op2)) { + if (op1.length > -op2) { + op1 = op1.slice(-op2) + op2 = ops2[i2++] + } else if (op1.length === -op2) { + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + op2 = op2 + op1.length + op1 = ops1[i1++] + } + } else if (isInsert(op1) && isRetain(op2)) { + if (op1.length > op2) { + operation.insert(op1.slice(0, op2)) + op1 = op1.slice(op2) + op2 = ops2[i2++] + } else if (op1.length === op2) { + operation.insert(op1) + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + operation.insert(op1) + op2 = op2 - op1.length + op1 = ops1[i1++] + } + } else if (isRetain(op1) && isRemove(op2)) { + if (op1 > -op2) { + operation.remove(op2) + op1 = op1 + op2 + op2 = ops2[i2++] + } else if (op1 === -op2) { + operation.remove(op2) + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + operation.remove(op1) + op2 = op2 + op1 + op1 = ops1[i1++] + } + } else { + throw new Error( + "This shouldn't happen: op1: " + + JSON.stringify(op1) + + ', op2: ' + + JSON.stringify(op2) + ) + } + } + return operation +} + +function getSimpleOp(operation, fn) { + const ops = operation.ops + switch (ops.length) { + case 1: + return ops[0] + case 2: + return isRetain(ops[0]) ? ops[1] : isRetain(ops[1]) ? ops[0] : null + case 3: + if (isRetain(ops[0]) && isRetain(ops[2])) { + return ops[1] + } + } + return null +} + +function getStartIndex(operation) { + if (isRetain(operation.ops[0])) { + return operation.ops[0] + } + return 0 +} + +// Transform takes two operations A and B that happened concurrently and +// produces two operations A' and B' (in an array) such that +// `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the +// heart of OT. +TextOperation.transform = function (operation1, operation2) { + if (operation1.baseLength !== operation2.baseLength) { + throw new Error('Both operations have to have the same base length') + } + + const operation1prime = new TextOperation() + const operation2prime = new TextOperation() + const ops1 = operation1.ops + const ops2 = operation2.ops + let i1 = 0 + let i2 = 0 + let op1 = ops1[i1++] + let op2 = ops2[i2++] + for (;;) { + // At every iteration of the loop, the imaginary cursor that both + // operation1 and operation2 have that operates on the input string must + // have the same position in the input string. + + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break + } + + // next two cases: one or both ops are insert ops + // => insert the string in the corresponding prime operation, skip it in + // the other one. If both op1 and op2 are insert ops, prefer op1. + if (isInsert(op1)) { + operation1prime.insert(op1) + operation2prime.retain(op1.length) + op1 = ops1[i1++] + continue + } + if (isInsert(op2)) { + operation1prime.retain(op2.length) + operation2prime.insert(op2) + op2 = ops2[i2++] + continue + } + + if (typeof op1 === 'undefined') { + throw new Error( + 'Cannot compose operations: first operation is too short.' + ) + } + if (typeof op2 === 'undefined') { + throw new Error('Cannot compose operations: first operation is too long.') + } + + let minl + if (isRetain(op1) && isRetain(op2)) { + // Simple case: retain/retain + if (op1 > op2) { + minl = op2 + op1 = op1 - op2 + op2 = ops2[i2++] + } else if (op1 === op2) { + minl = op2 + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + minl = op1 + op2 = op2 - op1 + op1 = ops1[i1++] + } + operation1prime.retain(minl) + operation2prime.retain(minl) + } else if (isRemove(op1) && isRemove(op2)) { + // Both operations remove the same string at the same position. We don't + // need to produce any operations, we just skip over the remove ops and + // handle the case that one operation removes more than the other. + if (-op1 > -op2) { + op1 = op1 - op2 + op2 = ops2[i2++] + } else if (op1 === op2) { + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + op2 = op2 - op1 + op1 = ops1[i1++] + } + // next two cases: remove/retain and retain/remove + } else if (isRemove(op1) && isRetain(op2)) { + if (-op1 > op2) { + minl = op2 + op1 = op1 + op2 + op2 = ops2[i2++] + } else if (-op1 === op2) { + minl = op2 + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + minl = -op1 + op2 = op2 + op1 + op1 = ops1[i1++] + } + operation1prime.remove(minl) + } else if (isRetain(op1) && isRemove(op2)) { + if (op1 > -op2) { + minl = -op2 + op1 = op1 + op2 + op2 = ops2[i2++] + } else if (op1 === -op2) { + minl = op1 + op1 = ops1[i1++] + op2 = ops2[i2++] + } else { + minl = op1 + op2 = op2 + op1 + op1 = ops1[i1++] + } + operation2prime.remove(minl) + } else { + throw new Error("The two operations aren't compatible") + } + } + + return [operation1prime, operation2prime] +} + +module.exports = TextOperation diff --git a/libraries/overleaf-editor-core/lib/origin/index.js b/libraries/overleaf-editor-core/lib/origin/index.js new file mode 100644 index 0000000000..2c6a228212 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/origin/index.js @@ -0,0 +1,54 @@ +'use strict' + +const assert = require('check-types').assert + +// Dependencies are loaded at the bottom of the file to mitigate circular +// dependency +let RestoreOrigin = null + +/** + * @constructor + * @param {string} kind + * @classdesc + * An Origin records where a {@link Change} came from. The Origin class handles + * simple tag origins, like "it came from rich text mode", or "it came from + * uploading files". Its subclasses record more detailed data for Changes such + * as restoring a version. + */ +function Origin(kind) { + assert.string(kind, 'Origin: bad kind') + + this.kind = kind +} + +/** + * Create an Origin from its raw form. + * + * @param {Object} [raw] + * @return {Origin | null} + */ +Origin.fromRaw = function originFromRaw(raw) { + if (!raw) return null + if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw) + return new Origin(raw.kind) +} + +/** + * Convert the Origin to raw form for storage or transmission. + * + * @return {Object} + */ +Origin.prototype.toRaw = function originToRaw() { + return { kind: this.kind } +} + +/** + * @return {string} + */ +Origin.prototype.getKind = function () { + return this.kind +} + +module.exports = Origin + +RestoreOrigin = require('./restore_origin') diff --git a/libraries/overleaf-editor-core/lib/origin/restore_origin.js b/libraries/overleaf-editor-core/lib/origin/restore_origin.js new file mode 100644 index 0000000000..180b8bc36f --- /dev/null +++ b/libraries/overleaf-editor-core/lib/origin/restore_origin.js @@ -0,0 +1,64 @@ +'use strict' + +const assert = require('check-types').assert + +const Origin = require('./') + +/** + * @classdesc + * When a {@link Change} is generated by restoring a previous version, this + * records the original version. We also store the timestamp of the restored + * version for display; technically, this is redundant, because we could + * recover it using the version ID. However, it would be very expensive to + * recover all referenced versions, and it is also possible that the change + * for the restored version will no longer exist, either because it was merged + * with other changes or was deleted. + * + * @see Origin + */ +class RestoreOrigin extends Origin { + /** + * @constructor + * @param {number} version that was restored + * @param {Date} timestamp from the restored version + */ + constructor(version, timestamp) { + assert.integer(version, 'RestoreOrigin: bad version') + assert.date(timestamp, 'RestoreOrigin: bad timestamp') + + super(RestoreOrigin.KIND) + this.version = version + this.timestamp = timestamp + } + + static fromRaw(raw) { + return new RestoreOrigin(raw.version, new Date(raw.timestamp)) + } + + /** @inheritdoc */ + toRaw() { + return { + kind: RestoreOrigin.KIND, + version: this.version, + timestamp: this.timestamp.toISOString(), + } + } + + /** + * @return {number} + */ + getVersion() { + return this.version + } + + /** + * @return {Date} + */ + getTimestamp() { + return this.timestamp + } +} + +RestoreOrigin.KIND = 'restore' + +module.exports = RestoreOrigin diff --git a/libraries/overleaf-editor-core/lib/ot_client.js b/libraries/overleaf-editor-core/lib/ot_client.js new file mode 100644 index 0000000000..b3eafbc213 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/ot_client.js @@ -0,0 +1,237 @@ +'use strict' + +const _ = require('lodash') +const BPromise = require('bluebird') + +const ChangeNote = require('./change_note') +const ChangeRequest = require('./change_request') +const Chunk = require('./chunk') +const Operation = require('./operation') + +/** + * @class + * @classdesc + * Operational Transformation client. + * + * See OT.md for explanation. + */ +function OtClient(_projectId, _editor, _blobStore, _socket) { + const STATE_DISCONNECTED = 0 + const STATE_LOADING = 1 + const STATE_READY = 2 + const STATE_WAITING = 3 + + let _version = null + let _state = STATE_DISCONNECTED + const _buffer = [] + let _ackVersion = null + let _outstanding = [] + let _pending = [] + const _waiting = [] + + this.connect = function otClientConnect() { + switch (_state) { + case STATE_DISCONNECTED: + _state = STATE_LOADING + _socket.emit('authenticate', { + projectId: _projectId, + token: 'letmein', + }) + break + default: + throw new Error('connect in state ' + _state) + } + } + + /** + * The latest project version number for which the client can construct the + * project content. + * + * @return {number} non-negative + */ + this.getVersion = function () { + return _version + } + + _socket.on('load', function otClientOnLoad(data) { + switch (_state) { + case STATE_LOADING: { + const chunk = Chunk.fromRaw(data) + const snapshot = chunk.getSnapshot() + snapshot.applyAll(chunk.getChanges(), { strict: true }) + _version = chunk.getEndVersion() + // TODO: we can get remote changes here, so it's not correct to wait for + // the editor to load before transitioning to the READY state + _editor.load(snapshot).then(function () { + _state = STATE_READY + }) + break + } + default: + throw new Error('loaded in state ' + _state) + } + }) + + // + // Local Operations + // + + function sendOutstandingChange() { + const changeRequest = new ChangeRequest(_version, _outstanding) + _socket.emit('change', changeRequest.toRaw()) + _state = STATE_WAITING + } + + function sendLocalOperation(operation) { + _outstanding.push(operation) + sendOutstandingChange() + } + + function queueLocalOperation(operation) { + _pending.push(operation) + } + + this.handleLocalOperation = function otClientHandleLocalOperation(operation) { + switch (_state) { + case STATE_READY: + sendLocalOperation(operation) + break + case STATE_WAITING: + queueLocalOperation(operation) + break + default: + throw new Error('local operation in state ' + _state) + } + } + + /** + * A promise that resolves when the project reaches the given version. + * + * @param {number} version non-negative + * @return {Promise} + */ + this.waitForVersion = function otClientWaitForVersion(version) { + if (!_waiting[version]) _waiting[version] = [] + return new BPromise(function (resolve, reject) { + _waiting[version].push(resolve) + }) + } + + function resolveWaitingPromises() { + for (const version in _waiting) { + if (!Object.prototype.hasOwnProperty.call(_waiting, version)) continue + if (version > _version) continue + _waiting[version].forEach(function (resolve) { + resolve() + }) + delete _waiting[version] + } + } + + // + // Messages from Server + // + + function advanceIfReady() { + if (_ackVersion !== null && _version === _ackVersion) { + _version += 1 + _ackVersion = null + handleAckReady() + advanceIfReady() + return + } + const changeNotes = _.remove(_buffer, function (changeNote) { + return changeNote.getBaseVersion() === _version + }) + if (changeNotes.length === 1) { + handleRemoteChangeReady(changeNotes[0].getChange()) + _version += 1 + advanceIfReady() + return + } + if (changeNotes.length !== 0) { + throw new Error('multiple remote changes in client version ' + _version) + } + } + + function bufferRemoteChangeNote(changeNote) { + const version = changeNote.getBaseVersion() + if (_.find(_buffer, 'baseVersion', version)) { + throw new Error('multiple changes in version ' + version) + } + if (version === _ackVersion) { + throw new Error('received change that was acked in ' + _ackVersion) + } + _buffer.push(changeNote) + } + + function handleAckReady() { + // console.log('handleAckReady') + if (_outstanding.length === 0) { + throw new Error('ack complete without outstanding change') + } + if (_state !== STATE_WAITING) { + throw new Error('ack complete in state ' + _state) + } + _editor.handleChangeAcknowledged() + resolveWaitingPromises() + if (_pending.length > 0) { + _outstanding = _pending + _pending = [] + sendOutstandingChange() + } else { + _outstanding = [] + _state = STATE_READY + } + } + + function handleRemoteChangeReady(change) { + if (_pending.length > 0) { + if (_outstanding.length === 0) { + throw new Error('pending change without outstanding change') + } + } + + Operation.transformMultiple(_outstanding, change.getOperations()) + Operation.transformMultiple(_pending, change.getOperations()) + + _editor.applyRemoteChange(change) + } + + _socket.on('ack', function otClientOnAck(data) { + switch (_state) { + case STATE_WAITING: { + const changeNote = ChangeNote.fromRaw(data) + _ackVersion = changeNote.getBaseVersion() + advanceIfReady() + break + } + default: + throw new Error('ack in state ' + _state) + } + }) + + _socket.on('change', function otClientOnChange(data) { + switch (_state) { + case STATE_READY: + case STATE_WAITING: + bufferRemoteChangeNote(ChangeNote.fromRaw(data)) + advanceIfReady() + break + default: + throw new Error('remote change in state ' + _state) + } + }) + + // + // Connection State + // TODO: socket.io error handling + // + + _socket.on('disconnect', function () { + _state = STATE_DISCONNECTED + // eslint-disable-next-line no-console + console.log('disconnected') // TODO: how do we handle disconnect? + }) +} +module.exports = OtClient diff --git a/libraries/overleaf-editor-core/lib/safe_pathname.js b/libraries/overleaf-editor-core/lib/safe_pathname.js new file mode 100644 index 0000000000..9b4ab79a1d --- /dev/null +++ b/libraries/overleaf-editor-core/lib/safe_pathname.js @@ -0,0 +1,91 @@ +/** @module */ +'use strict' + +const path = require('path') + +/** + * Regular expressions for Overleaf v2 taken from + * https://github.com/sharelatex/web-sharelatex/blob/master/app/coffee/Features/Project/SafePath.coffee + */ + +// +// Regex of characters that are invalid in filenames +// +// eslint-disable-next-line no-control-regex +const BAD_CHAR_RX = /[/*\u0000-\u001F\u007F\u0080-\u009F\uD800-\uDFFF]/g + +// +// Regex of filename patterns that are invalid ("." ".." and leading/trailing +// whitespace) +// +const BAD_FILE_RX = /(^\.$)|(^\.\.$)|(^\s+)|(\s+$)/g + +// +// Put a block on filenames which match javascript property names, as they +// can cause exceptions where the code puts filenames into a hash. This is a +// temporary workaround until the code in other places is made safe against +// property names. +// +// See https://github.com/overleaf/write_latex/wiki/Using-javascript-Objects-as-Maps +// +const BLOCKED_FILE_RX = + /^(prototype|constructor|toString|toLocaleString|valueOf|hasOwnProperty|isPrototypeOf|propertyIsEnumerable|__defineGetter__|__lookupGetter__|__defineSetter__|__lookupSetter__|__proto__)$/ + +// +// Maximum path length, in characters. This is fairly arbitrary. +// +const MAX_PATH = 1024 + +/** + * Replace invalid characters and filename patterns in a filename with + * underscores. + */ +function cleanPart(filename) { + filename = filename.replace(BAD_CHAR_RX, '_') + filename = filename.replace(BAD_FILE_RX, function (match) { + return new Array(match.length + 1).join('_') + }) + return filename +} + +/** + * All pathnames in a Snapshot must be clean. We want pathnames that: + * + * 1. are unambiguous (e.g. no `.`s or redundant path separators) + * 2. do not allow directory traversal attacks (e.g. no `..`s or absolute paths) + * 3. do not contain leading/trailing space + * 4. do not contain the character '*' in filenames + * + * We normalise the pathname, split it by the separator and then clean each part + * as a filename + * + * @param {string} pathname + * @return {String} + */ +exports.clean = function (pathname) { + pathname = path.normalize(pathname) + pathname = pathname.replace(/\\/g, '/') // workaround for IE + pathname = pathname.replace(/\/+/g, '/') // no multiple slashes + pathname = pathname.replace(/^(\/.*)$/, '_$1') // no leading / + pathname = pathname.replace(/^(.+)\/$/, '$1') // no trailing / + pathname = pathname.replace(/^ *(.*)$/, '$1') // no leading spaces + pathname = pathname.replace(/^(.*[^ ]) *$/, '$1') // no trailing spaces + if (pathname.length === 0) pathname = '_' + pathname = pathname.split('/').map(cleanPart).join('/') + pathname = pathname.replace(BLOCKED_FILE_RX, '@$1') + return pathname +} + +/** + * A pathname is clean (see clean) and not too long. + * + * @param {string} pathname + * @return {Boolean} + */ +exports.isClean = function pathnameIsClean(pathname) { + return ( + exports.clean(pathname) === pathname && + pathname.length <= MAX_PATH && + pathname.length > 0 + ) +} diff --git a/libraries/overleaf-editor-core/lib/snapshot.js b/libraries/overleaf-editor-core/lib/snapshot.js new file mode 100644 index 0000000000..c635533f31 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/snapshot.js @@ -0,0 +1,240 @@ +'use strict' + +const assert = require('check-types').assert +const BPromise = require('bluebird') +const OError = require('@overleaf/o-error') + +const FileMap = require('./file_map') +const V2DocVersions = require('./v2_doc_versions') + +/** + * @typedef {import("./types").BlobStore} BlobStore + * @typedef {import("./change")} Change + * @typedef {import("./operation/text_operation")} TextOperation + */ + +/** + * @classdesc A Snapshot represents the state of a {@link Project} at a + * particular version. + */ +class Snapshot { + static fromRaw(raw) { + assert.object(raw.files, 'bad raw.files') + return new Snapshot( + FileMap.fromRaw(raw.files), + raw.projectVersion, + V2DocVersions.fromRaw(raw.v2DocVersions) + ) + } + + toRaw() { + const raw = { + files: this.fileMap.toRaw(), + } + if (this.projectVersion) raw.projectVersion = this.projectVersion + if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw() + return raw + } + + /** + * @constructor + * @param {FileMap} [fileMap] + * @param {string} [projectVersion] + * @param {V2DocVersions} [v2DocVersions] + */ + constructor(fileMap, projectVersion, v2DocVersions) { + assert.maybe.instance(fileMap, FileMap, 'bad fileMap') + + this.fileMap = fileMap || new FileMap({}) + this.projectVersion = projectVersion + this.v2DocVersions = v2DocVersions + } + + /** + * @return {string | null | undefined} + */ + getProjectVersion() { + return this.projectVersion + } + + setProjectVersion(projectVersion) { + assert.maybe.match( + projectVersion, + Snapshot.PROJECT_VERSION_RX, + 'Snapshot: bad projectVersion' + ) + this.projectVersion = projectVersion + } + + /** + * @return {V2DocVersions | null | undefined} + */ + getV2DocVersions() { + return this.v2DocVersions + } + + setV2DocVersions(v2DocVersions) { + assert.maybe.instance( + v2DocVersions, + V2DocVersions, + 'Snapshot: bad v2DocVersions' + ) + this.v2DocVersions = v2DocVersions + } + + updateV2DocVersions(v2DocVersions) { + // merge new v2DocVersions into this.v2DocVersions + v2DocVersions.applyTo(this) + } + + /** + * The underlying file map. + * @return {FileMap} + */ + getFileMap() { + return this.fileMap + } + + /** + * The pathnames of all of the files. + * + * @return {Array.} in no particular order + */ + getFilePathnames() { + return this.fileMap.getPathnames() + } + + /** + * Get a File by its pathname. + * @see FileMap#getFile + */ + getFile(pathname) { + return this.fileMap.getFile(pathname) + } + + /** + * Add the given file to the snapshot. + * @see FileMap#addFile + */ + addFile(pathname, file) { + this.fileMap.addFile(pathname, file) + } + + /** + * Move or remove a file. + * @see FileMap#moveFile + */ + moveFile(pathname, newPathname) { + this.fileMap.moveFile(pathname, newPathname) + } + + /** + * The number of files in the snapshot. + * + * @return {number} + */ + countFiles() { + return this.fileMap.countFiles() + } + + /** + * Edit the content of an editable file. + * + * Throws an error if no file with the given name exists. + * + * @param {string} pathname + * @param {TextOperation} textOperation + */ + editFile(pathname, textOperation) { + const file = this.fileMap.getFile(pathname) + if (!file) { + throw new Snapshot.EditMissingFileError( + `can't find file for editing: ${pathname}` + ) + } + file.edit(textOperation) + } + + /** + * Apply all changes in sequence. Modifies the snapshot in place. + * + * Ignore recoverable errors (caused by historical bad data) unless opts.strict is true + * + * @param {Change[]} changes + * @param {object} opts + * @param {boolean} opts.strict - do not ignore recoverable errors + */ + applyAll(changes, opts) { + for (const change of changes) { + change.applyTo(this, opts) + } + } + + /** + * If the Files in this Snapshot reference blob hashes, add them to the given + * set. + * + * @param {Set.} blobHashes + */ + findBlobHashes(blobHashes) { + // eslint-disable-next-line array-callback-return + this.fileMap.map(file => { + const hash = file.getHash() + if (hash) blobHashes.add(hash) + }) + } + + /** + * Load all of the files in this snapshot. + * + * @param {string} kind see {File#load} + * @param {BlobStore} blobStore + * @return {Promise} + */ + loadFiles(kind, blobStore) { + return BPromise.props(this.fileMap.map(file => file.load(kind, blobStore))) + } + + /** + * Store each of the files in this snapshot and return the raw snapshot for + * long term storage. + * + * @param {BlobStore} blobStore + * @param {number} [concurrency] + * @return {Promise.} + */ + store(blobStore, concurrency) { + assert.maybe.number(concurrency, 'bad concurrency') + + const projectVersion = this.projectVersion + const rawV2DocVersions = this.v2DocVersions + ? this.v2DocVersions.toRaw() + : undefined + return this.fileMap + .mapAsync(file => file.store(blobStore), concurrency) + .then(rawFiles => { + return { + files: rawFiles, + projectVersion, + v2DocVersions: rawV2DocVersions, + } + }) + } + + /** + * Create a deep clone of this snapshot. + * + * @return {Snapshot} + */ + clone() { + return Snapshot.fromRaw(this.toRaw()) + } +} + +class EditMissingFileError extends OError {} +Snapshot.EditMissingFileError = EditMissingFileError + +Snapshot.PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$' +Snapshot.PROJECT_VERSION_RX = new RegExp(Snapshot.PROJECT_VERSION_RX_STRING) + +module.exports = Snapshot diff --git a/libraries/overleaf-editor-core/lib/types.ts b/libraries/overleaf-editor-core/lib/types.ts new file mode 100644 index 0000000000..b396a8fd93 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/types.ts @@ -0,0 +1,13 @@ +import Blob from './blob' +import BPromise from 'bluebird' + +export type BlobStore = { + getString(hash: string): BPromise + putString(content: string): BPromise +} + +export type StringFileRawData = { + content: string +} + +export type RawV2DocVersions = Record diff --git a/libraries/overleaf-editor-core/lib/util.js b/libraries/overleaf-editor-core/lib/util.js new file mode 100644 index 0000000000..7647425ce9 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/util.js @@ -0,0 +1,13 @@ +/* + * Misc functions + */ + +'use strict' + +/* + * return true/false if the given string contains non-BMP chars + */ +exports.containsNonBmpChars = function utilContainsNonBmpChars(str) { + // check for first (high) surrogate in a non-BMP character + return /[\uD800-\uDBFF]/.test(str) +} diff --git a/libraries/overleaf-editor-core/lib/v2_doc_versions.js b/libraries/overleaf-editor-core/lib/v2_doc_versions.js new file mode 100644 index 0000000000..4b5500cf2c --- /dev/null +++ b/libraries/overleaf-editor-core/lib/v2_doc_versions.js @@ -0,0 +1,55 @@ +'use strict' + +const _ = require('lodash') + +/** + * @typedef {import("./file")} File + * @typedef {import("./types").RawV2DocVersions} RawV2DocVersions + */ + +/** + * @constructor + * @param {RawV2DocVersions} data + * @classdesc + */ +function V2DocVersions(data) { + this.data = data || {} +} + +V2DocVersions.fromRaw = function v2DocVersionsFromRaw(raw) { + if (!raw) return undefined + return new V2DocVersions(raw) +} + +/** + * @abstract + */ +V2DocVersions.prototype.toRaw = function () { + if (!this.data) return null + const raw = _.clone(this.data) + return raw +} + +/** + * Clone this object. + * + * @return {V2DocVersions} a new object of the same type + */ +V2DocVersions.prototype.clone = function v2DocVersionsClone() { + return V2DocVersions.fromRaw(this.toRaw()) +} + +V2DocVersions.prototype.applyTo = function v2DocVersionsApplyTo(snapshot) { + // Only update the snapshot versions if we have new versions + if (!_.size(this.data)) return + + // Create v2DocVersions in snapshot if it does not exist + // otherwise update snapshot v2docversions + if (!snapshot.v2DocVersions) { + snapshot.v2DocVersions = this.clone() + } else { + _.assign(snapshot.v2DocVersions.data, this.data) + } +} + +module.exports = V2DocVersions diff --git a/libraries/overleaf-editor-core/package.json b/libraries/overleaf-editor-core/package.json new file mode 100644 index 0000000000..810cb9126d --- /dev/null +++ b/libraries/overleaf-editor-core/package.json @@ -0,0 +1,31 @@ +{ + "name": "overleaf-editor-core", + "version": "1.0.0", + "description": "Library shared between the editor server and clients.", + "main": "index.js", + "scripts": { + "test": "mocha", + "format": "prettier --list-different $PWD/'**/*.js'", + "format:fix": "prettier --write $PWD/'**/*.js'", + "lint": "eslint --max-warnings 0 --format unix lib test && tsc", + "lint:fix": "eslint --fix lib test", + "test:ci": "npm run test", + "coverage": "istanbul cover _mocha" + }, + "author": "team@overleaf.com", + "license": "Proprietary", + "private": true, + "devDependencies": { + "@types/bluebird": "^3.5.30", + "chai": "^3.3.0", + "istanbul": "^0.4.5", + "mocha": "^6.1.4", + "typescript": "^4.5.5" + }, + "dependencies": { + "@overleaf/o-error": "*", + "bluebird": "^3.1.1", + "check-types": "^5.1.0", + "lodash": "^4.17.19" + } +} diff --git a/libraries/overleaf-editor-core/test/change.test.js b/libraries/overleaf-editor-core/test/change.test.js new file mode 100644 index 0000000000..9659110200 --- /dev/null +++ b/libraries/overleaf-editor-core/test/change.test.js @@ -0,0 +1,37 @@ +'use strict' + +const { expect } = require('chai') +const core = require('..') +const Change = core.Change +const File = core.File +const Operation = core.Operation + +describe('Change', function () { + describe('findBlobHashes', function () { + it('finds blob hashes from operations', function () { + const blobHashes = new Set() + + const change = Change.fromRaw({ + operations: [], + timestamp: '2015-03-05T12:03:53.035Z', + authors: [null], + }) + + change.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(0) + + // AddFile with content doesn't have a hash. + change.pushOperation(Operation.addFile('a.txt', File.fromString('a'))) + change.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(0) + + // AddFile with hash should give us a hash. + change.pushOperation( + Operation.addFile('b.txt', File.fromHash(File.EMPTY_FILE_HASH)) + ) + change.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(1) + expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/edit_file_operation.test.js b/libraries/overleaf-editor-core/test/edit_file_operation.test.js new file mode 100644 index 0000000000..5e3ad8625d --- /dev/null +++ b/libraries/overleaf-editor-core/test/edit_file_operation.test.js @@ -0,0 +1,80 @@ +'use strict' + +const { expect } = require('chai') + +const ot = require('..') +const File = ot.File +const Operation = ot.Operation +const TextOperation = ot.TextOperation + +describe('EditFileOperation', function () { + function edit(pathname, textOperationJsonObject) { + return Operation.editFile( + pathname, + TextOperation.fromJSON(textOperationJsonObject) + ) + } + + describe('canBeComposedWith', function () { + it('on the same file', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('foo.tex', [1, 'y']) + expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be + .true + }) + + it('on different files', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('bar.tex', ['y']) + expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be + .false + }) + + it('with a different type of opperation', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = Operation.addFile( + 'bar.tex', + File.fromString('') + ) + expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be + .false + }) + + it('with incompatible lengths', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('foo.tex', [2, 'y']) + expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be + .false + }) + }) + + describe('canBeComposedWithForUndo', function () { + it('can', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('foo.tex', [1, 'y']) + expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to + .be.true + }) + + it('cannot', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('foo.tex', ['y', 1, 'z']) + expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to + .be.false + }) + }) + + describe('compose', function () { + it('composes text operations', function () { + const editFileOperation1 = edit('foo.tex', ['x']) + const editFileOperation2 = edit('foo.tex', [1, 'y']) + const composedFileOperation = + editFileOperation1.compose(editFileOperation2) + const expectedComposedFileOperation = edit('foo.tex', ['xy']) + expect(composedFileOperation).to.deep.equal(expectedComposedFileOperation) + + // check that the original operation wasn't modified + expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x'])) + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/file.test.js b/libraries/overleaf-editor-core/test/file.test.js new file mode 100644 index 0000000000..11c3847916 --- /dev/null +++ b/libraries/overleaf-editor-core/test/file.test.js @@ -0,0 +1,94 @@ +'use strict' + +const { expect } = require('chai') +const FakeBlobStore = require('./support/fake_blob_store') +const ot = require('..') +const File = ot.File + +describe('File', function () { + it('can have attached metadata', function () { + // no metadata + let file = File.fromString('foo') + expect(file.getMetadata()).to.eql({}) + + // metadata passed in at construction time + file = File.fromString('foo', { main: true }) + expect(file.getMetadata()).to.eql({ main: true }) + + // metadata set at runtime + file.setMetadata({ main: false }) + expect(file.getMetadata()).to.eql({ main: false }) + }) + + describe('toRaw', function () { + it('returns non-empty metadata', function () { + const metadata = { main: true } + const file = File.fromHash(File.EMPTY_FILE_HASH, metadata) + expect(file.toRaw()).to.eql({ + hash: File.EMPTY_FILE_HASH, + metadata: metadata, + }) + + delete file.getMetadata().main + expect(file.toRaw()).to.eql({ hash: File.EMPTY_FILE_HASH }) + }) + + it('returns a deep clone of metadata', function () { + const metadata = { externalFile: { id: 123 } } + const file = File.fromHash(File.EMPTY_FILE_HASH, metadata) + const raw = file.toRaw() + const fileMetadata = file.getMetadata() + const rawMetadata = raw.metadata + expect(rawMetadata).not.to.equal(fileMetadata) + expect(rawMetadata).to.deep.equal(fileMetadata) + }) + }) + + describe('store', function () { + it('does not return empty metadata', function () { + const file = File.fromHash(File.EMPTY_FILE_HASH) + const fakeBlobStore = new FakeBlobStore() + return file.store(fakeBlobStore).then(raw => { + expect(raw).to.eql({ hash: File.EMPTY_FILE_HASH }) + }) + }) + + it('returns non-empty metadata', function () { + const metadata = { main: true } + const file = File.fromHash(File.EMPTY_FILE_HASH, metadata) + const fakeBlobStore = new FakeBlobStore() + return file.store(fakeBlobStore).then(raw => { + expect(raw).to.eql({ + hash: File.EMPTY_FILE_HASH, + metadata: metadata, + }) + }) + }) + + it('returns a deep clone of metadata', function () { + const metadata = { externalFile: { id: 123 } } + const file = File.fromHash(File.EMPTY_FILE_HASH, metadata) + const fakeBlobStore = new FakeBlobStore() + return file.store(fakeBlobStore).then(raw => { + raw.metadata.externalFile.id = 456 + expect(file.getMetadata().externalFile.id).to.equal(123) + }) + }) + }) + + describe('with string data', function () { + it('can be created from a string', function () { + const file = File.fromString('foo') + expect(file.getContent()).to.equal('foo') + }) + }) + + describe('with hollow string data', function () { + it('can be cloned', function () { + const file = File.createHollow(null, 0) + expect(file.getStringLength()).to.equal(0) + const clone = file.clone() + expect(clone.getStringLength()).to.equal(0) + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/file_map.test.js b/libraries/overleaf-editor-core/test/file_map.test.js new file mode 100644 index 0000000000..33e36e27f8 --- /dev/null +++ b/libraries/overleaf-editor-core/test/file_map.test.js @@ -0,0 +1,202 @@ +'use strict' + +const { expect } = require('chai') +const BPromise = require('bluebird') +const _ = require('lodash') + +const ot = require('..') +const File = ot.File +const FileMap = ot.FileMap + +describe('FileMap', function () { + function makeTestFile(pathname) { + return File.fromString(pathname) + } + + function makeTestFiles(pathnames) { + return _.zipObject(pathnames, _.map(pathnames, makeTestFile)) + } + + function makeFileMap(pathnames) { + return new FileMap(makeTestFiles(pathnames)) + } + + it('allows construction with a single file', function () { + makeFileMap(['a']) + }) + + it('allows folders to differ by case', function () { + expect(() => { + makeFileMap(['a/b', 'A/c']) + }).not.to.throw + expect(() => { + makeFileMap(['a/b/c', 'A/b/d']) + }).not.to.throw + expect(() => { + makeFileMap(['a/b/c', 'a/B/d']) + }).not.to.throw + }) + + it('does not allow conflicting paths on construct', function () { + expect(() => { + makeFileMap(['a', 'a/b']) + }).to.throw(FileMap.PathnameConflictError) + }) + + it('detects conflicting paths with characters that sort before /', function () { + const fileMap = makeFileMap(['a', 'a!']) + expect(fileMap.wouldConflict('a/b')).to.be.truthy + }) + + it('detects conflicting paths', function () { + const fileMap = makeFileMap(['a/b/c']) + expect(fileMap.wouldConflict('a/b/c/d')).to.be.truthy + expect(fileMap.wouldConflict('a')).to.be.truthy + expect(fileMap.wouldConflict('b')).to.be.falsy + expect(fileMap.wouldConflict('a/b')).to.be.truthy + expect(fileMap.wouldConflict('a/c')).to.be.falsy + expect(fileMap.wouldConflict('a/b/c')).to.be.falsy + expect(fileMap.wouldConflict('a/b/d')).to.be.falsy + expect(fileMap.wouldConflict('d/b/c')).to.be.falsy + }) + + it('allows paths that differ by case', function () { + const fileMap = makeFileMap(['a/b/c']) + expect(fileMap.wouldConflict('a/b/C')).to.be.falsy + expect(fileMap.wouldConflict('A')).to.be.falsy + expect(fileMap.wouldConflict('A/b')).to.be.falsy + expect(fileMap.wouldConflict('a/B')).to.be.falsy + expect(fileMap.wouldConflict('A/B')).to.be.falsy + }) + + it('does not add a file with a conflicting path', function () { + const fileMap = makeFileMap(['a/b']) + const file = makeTestFile('a/b/c') + + expect(() => { + fileMap.addFile('a/b/c', file) + }).to.throw(FileMap.PathnameConflictError) + }) + + it('does not move a file to a conflicting path', function () { + const fileMap = makeFileMap(['a/b', 'a/c']) + + expect(() => { + fileMap.moveFile('a/b', 'a') + }).to.throw(FileMap.PathnameConflictError) + }) + + it('errors when trying to move a non-existent file', function () { + const fileMap = makeFileMap(['a']) + expect(() => fileMap.moveFile('b', 'a')).to.throw(FileMap.FileNotFoundError) + }) + + it('moves a file over an empty folder', function () { + const fileMap = makeFileMap(['a/b']) + fileMap.moveFile('a/b', 'a') + expect(fileMap.countFiles()).to.equal(1) + expect(fileMap.getFile('a')).to.exist + expect(fileMap.getFile('a').getContent()).to.equal('a/b') + }) + + it('does not move a file over a non-empty folder', function () { + const fileMap = makeFileMap(['a/b', 'a/c']) + expect(() => { + fileMap.moveFile('a/b', 'a') + }).to.throw(FileMap.PathnameConflictError) + }) + + it('does not overwrite filename that differs by case on add', function () { + const fileMap = makeFileMap(['a']) + fileMap.addFile('A', makeTestFile('A')) + expect(fileMap.countFiles()).to.equal(2) + expect(fileMap.files.a).to.exist + expect(fileMap.files.A).to.exist + expect(fileMap.getFile('a')).to.exist + expect(fileMap.getFile('A').getContent()).to.equal('A') + }) + + it('changes case on move', function () { + const fileMap = makeFileMap(['a']) + fileMap.moveFile('a', 'A') + expect(fileMap.countFiles()).to.equal(1) + expect(fileMap.files.a).not.to.exist + expect(fileMap.files.A).to.exist + expect(fileMap.getFile('A').getContent()).to.equal('a') + }) + + it('does not overwrite filename that differs by case on move', function () { + const fileMap = makeFileMap(['a', 'b']) + fileMap.moveFile('a', 'B') + expect(fileMap.countFiles()).to.equal(2) + expect(fileMap.files.a).not.to.exist + expect(fileMap.files.b).to.exist + expect(fileMap.files.B).to.exist + expect(fileMap.getFile('B').getContent()).to.equal('a') + }) + + it('does not find pathname that differs by case', function () { + const fileMap = makeFileMap(['a']) + expect(fileMap.getFile('a')).to.exist + expect(fileMap.getFile('A')).not.to.exist + expect(fileMap.getFile('b')).not.to.exist + }) + + it('does not allow non-safe pathnames', function () { + expect(() => { + makeFileMap(['c*']) + }).to.throw(FileMap.BadPathnameError) + + const fileMap = makeFileMap([]) + + expect(() => { + fileMap.addFile('c*', makeTestFile('c:')) + }).to.throw(FileMap.BadPathnameError) + + fileMap.addFile('a', makeTestFile('a')) + expect(() => { + fileMap.moveFile('a', 'c*') + }).to.throw(FileMap.BadPathnameError) + + expect(() => { + fileMap.addFile('hasOwnProperty', makeTestFile('hasOwnProperty')) + fileMap.addFile('anotherFile', makeTestFile('anotherFile')) + }).to.throw() + }) + + it('removes a file', function () { + const fileMap = makeFileMap(['a', 'b']) + fileMap.removeFile('a') + expect(fileMap.countFiles()).to.equal(1) + expect(fileMap.files.a).not.to.exist + expect(fileMap.files.b).to.exist + }) + + it('errors when trying to remove a non-existent file', function () { + const fileMap = makeFileMap(['a']) + expect(() => fileMap.removeFile('b')).to.throw(FileMap.FileNotFoundError) + }) + + it('has mapAsync', function () { + const concurrency = 1 + return BPromise.map( + [ + [[], {}], + [['a'], { a: 'a-a' }], // the test is to map to "content-pathname" + [['a', 'b'], { a: 'a-a', b: 'b-b' }], + ], + test => { + const input = test[0] + const expectedOutput = test[1] + const fileMap = makeFileMap(input) + return fileMap + .mapAsync((file, pathname) => { + return file.getContent() + '-' + pathname + }, concurrency) + .then(result => { + expect(result).to.deep.equal(expectedOutput) + }) + } + ) + }) +}) diff --git a/libraries/overleaf-editor-core/test/history.test.js b/libraries/overleaf-editor-core/test/history.test.js new file mode 100644 index 0000000000..c5be9d8d60 --- /dev/null +++ b/libraries/overleaf-editor-core/test/history.test.js @@ -0,0 +1,42 @@ +'use strict' + +const { expect } = require('chai') +const core = require('..') +const Change = core.Change +const File = core.File +const History = core.History +const Operation = core.Operation +const Snapshot = core.Snapshot + +describe('History', function () { + describe('findBlobHashes', function () { + it('finds blob hashes from snapshot and changes', function () { + const history = new History(new Snapshot(), []) + + const blobHashes = new Set() + history.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(0) + + // Add a file with a hash to the snapshot. + history.getSnapshot().addFile('foo', File.fromHash(File.EMPTY_FILE_HASH)) + history.findBlobHashes(blobHashes) + expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH]) + + // Add a file with a hash to the changes. + const testHash = 'a'.repeat(40) + const change = Change.fromRaw({ + operations: [], + timestamp: '2015-03-05T12:03:53.035Z', + authors: [null], + }) + change.pushOperation(Operation.addFile('bar', File.fromHash(testHash))) + + history.pushChanges([change]) + history.findBlobHashes(blobHashes) + expect(Array.from(blobHashes)).to.have.members([ + File.EMPTY_FILE_HASH, + testHash, + ]) + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/hollow_string_file_data.test.js b/libraries/overleaf-editor-core/test/hollow_string_file_data.test.js new file mode 100644 index 0000000000..e40fec228f --- /dev/null +++ b/libraries/overleaf-editor-core/test/hollow_string_file_data.test.js @@ -0,0 +1,22 @@ +'use strict' + +const { expect } = require('chai') +const ot = require('..') +const HollowStringFileData = require('../lib/file_data/hollow_string_file_data') +const TextOperation = ot.TextOperation + +describe('HollowStringFileData', function () { + it('validates string length when edited', function () { + const maxLength = TextOperation.MAX_STRING_LENGTH + const fileData = new HollowStringFileData(maxLength) + expect(fileData.getStringLength()).to.equal(maxLength) + + expect(() => { + fileData.edit(new TextOperation().retain(maxLength).insert('x')) + }).to.throw(TextOperation.TooLongError) + expect(fileData.getStringLength()).to.equal(maxLength) + + fileData.edit(new TextOperation().retain(maxLength - 1).remove(1)) + expect(fileData.getStringLength()).to.equal(maxLength - 1) + }) +}) diff --git a/libraries/overleaf-editor-core/test/label.test.js b/libraries/overleaf-editor-core/test/label.test.js new file mode 100644 index 0000000000..448b6f0052 --- /dev/null +++ b/libraries/overleaf-editor-core/test/label.test.js @@ -0,0 +1,17 @@ +'use strict' + +const { expect } = require('chai') +const ot = require('..') +const Label = ot.Label + +describe('Label', function () { + it('can be created by an anonymous author', function () { + const label = Label.fromRaw({ + text: 'test', + authorId: null, + timestamp: '2016-01-01T00:00:00Z', + version: 123, + }) + expect(label.getAuthorId()).to.be.null + }) +}) diff --git a/libraries/overleaf-editor-core/test/lazy_string_file_data.test.js b/libraries/overleaf-editor-core/test/lazy_string_file_data.test.js new file mode 100644 index 0000000000..053f25b689 --- /dev/null +++ b/libraries/overleaf-editor-core/test/lazy_string_file_data.test.js @@ -0,0 +1,98 @@ +'use strict' + +const _ = require('lodash') +const { expect } = require('chai') + +const ot = require('..') +const File = ot.File +const TextOperation = ot.TextOperation +const LazyStringFileData = require('../lib/file_data/lazy_string_file_data') + +describe('LazyStringFileData', function () { + it('uses raw text operations for toRaw and fromRaw', function () { + const testHash = File.EMPTY_FILE_HASH + const fileData = new LazyStringFileData(testHash, 0) + let roundTripFileData + + expect(fileData.toRaw()).to.eql({ + hash: testHash, + stringLength: 0, + }) + roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw()) + expect(roundTripFileData.getHash()).to.equal(testHash) + expect(roundTripFileData.getStringLength()).to.equal(0) + expect(roundTripFileData.getTextOperations()).to.have.length(0) + + fileData.edit(new TextOperation().insert('a')) + expect(fileData.toRaw()).to.eql({ + hash: testHash, + stringLength: 1, + textOperations: [['a']], + }) + roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw()) + expect(roundTripFileData.getHash()).not.to.exist // file has changed + expect(roundTripFileData.getStringLength()).to.equal(1) + expect(roundTripFileData.getTextOperations()).to.have.length(1) + expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1) + + fileData.edit(new TextOperation().retain(1).insert('b')) + expect(fileData.toRaw()).to.eql({ + hash: testHash, + stringLength: 2, + textOperations: [['a'], [1, 'b']], + }) + roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw()) + expect(roundTripFileData.getHash()).not.to.exist // file has changed + expect(roundTripFileData.getStringLength()).to.equal(2) + expect(roundTripFileData.getTextOperations()).to.have.length(2) + expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1) + expect(roundTripFileData.getTextOperations()[1].ops).to.have.length(2) + }) + + it('validates operations when edited', function () { + const testHash = File.EMPTY_FILE_HASH + const fileData = new LazyStringFileData(testHash, 0) + expect(fileData.getHash()).equal(testHash) + expect(fileData.getByteLength()).to.equal(0) // approximately + expect(fileData.getStringLength()).to.equal(0) + expect(fileData.getTextOperations()).to.have.length(0) + + fileData.edit(new TextOperation().insert('a')) + expect(fileData.getHash()).not.to.exist + expect(fileData.getByteLength()).to.equal(1) // approximately + expect(fileData.getStringLength()).to.equal(1) + expect(fileData.getTextOperations()).to.have.length(1) + + expect(() => { + fileData.edit(new TextOperation().retain(10)) + }).to.throw(TextOperation.ApplyError) + expect(fileData.getHash()).not.to.exist + expect(fileData.getByteLength()).to.equal(1) // approximately + expect(fileData.getStringLength()).to.equal(1) + expect(fileData.getTextOperations()).to.have.length(1) + }) + + it('validates string length when edited', function () { + const testHash = File.EMPTY_FILE_HASH + const fileData = new LazyStringFileData(testHash, 0) + expect(fileData.getHash()).equal(testHash) + expect(fileData.getByteLength()).to.equal(0) // approximately + expect(fileData.getStringLength()).to.equal(0) + expect(fileData.getTextOperations()).to.have.length(0) + + const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH) + fileData.edit(new TextOperation().insert(longString)) + expect(fileData.getHash()).not.to.exist + expect(fileData.getByteLength()).to.equal(longString.length) // approximate + expect(fileData.getStringLength()).to.equal(longString.length) + expect(fileData.getTextOperations()).to.have.length(1) + + expect(() => { + fileData.edit(new TextOperation().retain(longString.length).insert('x')) + }).to.throw(TextOperation.TooLongError) + expect(fileData.getHash()).not.to.exist + expect(fileData.getByteLength()).to.equal(longString.length) // approximate + expect(fileData.getStringLength()).to.equal(longString.length) + expect(fileData.getTextOperations()).to.have.length(1) + }) +}) diff --git a/libraries/overleaf-editor-core/test/move_file_operation.test.js b/libraries/overleaf-editor-core/test/move_file_operation.test.js new file mode 100644 index 0000000000..4a55d122c2 --- /dev/null +++ b/libraries/overleaf-editor-core/test/move_file_operation.test.js @@ -0,0 +1,42 @@ +'use strict' + +const { expect } = require('chai') +const ot = require('..') +const File = ot.File +const MoveFileOperation = ot.MoveFileOperation +const Snapshot = ot.Snapshot + +describe('MoveFileOperation', function () { + function makeEmptySnapshot() { + return new Snapshot() + } + + function makeOneFileSnapshot() { + const snapshot = makeEmptySnapshot() + snapshot.addFile('foo', File.fromString('test: foo')) + return snapshot + } + + function makeTwoFileSnapshot() { + const snapshot = makeOneFileSnapshot() + snapshot.addFile('bar', File.fromString('test: bar')) + return snapshot + } + + it('moves a file over another', function () { + const snapshot = makeOneFileSnapshot() + const operation = new MoveFileOperation('foo', 'bar') + operation.applyTo(snapshot) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile('bar').getContent()).to.equal('test: foo') + }) + + it('moves a file to another pathname', function () { + const snapshot = makeTwoFileSnapshot() + const operation = new MoveFileOperation('foo', 'a') + operation.applyTo(snapshot) + expect(snapshot.countFiles()).to.equal(2) + expect(snapshot.getFile('a').getContent()).to.equal('test: foo') + expect(snapshot.getFile('bar').getContent()).to.equal('test: bar') + }) +}) diff --git a/libraries/overleaf-editor-core/test/operation.test.js b/libraries/overleaf-editor-core/test/operation.test.js new file mode 100644 index 0000000000..7e03025c98 --- /dev/null +++ b/libraries/overleaf-editor-core/test/operation.test.js @@ -0,0 +1,746 @@ +'use strict' + +const _ = require('lodash') +const { expect } = require('chai') + +const ot = require('..') +const File = ot.File +const AddFileOperation = ot.AddFileOperation +const MoveFileOperation = ot.MoveFileOperation +const EditFileOperation = ot.EditFileOperation +const NoOperation = ot.NoOperation +const Operation = ot.Operation +const TextOperation = ot.TextOperation +const Snapshot = ot.Snapshot + +describe('Operation', function () { + function makeEmptySnapshot() { + return new Snapshot() + } + + function makeOneFileSnapshot() { + const snapshot = makeEmptySnapshot() + snapshot.addFile('foo', File.fromString('')) + return snapshot + } + + function makeTwoFileSnapshot() { + const snapshot = makeOneFileSnapshot() + snapshot.addFile('bar', File.fromString('a')) + return snapshot + } + + function addFile(pathname, content) { + return new AddFileOperation(pathname, File.fromString(content)) + } + + function roundTripOperation(operation) { + return Operation.fromRaw(operation.toRaw()) + } + + function deepCopySnapshot(snapshot) { + return Snapshot.fromRaw(snapshot.toRaw()) + } + + function runConcurrently(operation0, operation1, snapshot) { + const operations = [ + // make sure they survive serialization + roundTripOperation(operation0), + roundTripOperation(operation1), + ] + const primeOperations = Operation.transform(operation0, operation1) + const originalSnapshot = snapshot || makeEmptySnapshot() + const snapshotA = deepCopySnapshot(originalSnapshot) + const snapshotB = deepCopySnapshot(originalSnapshot) + + operations[0].applyTo(snapshotA) + operations[1].applyTo(snapshotB) + + primeOperations[0].applyTo(snapshotB) + primeOperations[1].applyTo(snapshotA) + expect(snapshotA).to.eql(snapshotB) + + return { + snapshot: snapshotA, + operations: operations, + primeOperations: primeOperations, + + log() { + console.log(this) + return this + }, + + expectNoTransform() { + expect(this.operations).to.eql(this.primeOperations) + return this + }, + + swap() { + return runConcurrently(operation1, operation0, originalSnapshot) + }, + + expectFiles(files) { + this.expectedFiles = files + expect(this.snapshot.countFiles()).to.equal(_.size(files)) + _.forOwn(files, (expectedFile, pathname) => { + if (_.isString(expectedFile)) { + expectedFile = { content: expectedFile, metadata: {} } + } + const file = this.snapshot.getFile(pathname) + expect(file.getContent()).to.equal(expectedFile.content) + expect(file.getMetadata()).to.eql(expectedFile.metadata) + }) + return this + }, + + expectSymmetry() { + if (!this.expectedFiles) { + throw new Error('must call expectFiles before expectSymmetry') + } + this.swap().expectFiles(this.expectedFiles) + return this + }, + + expectPrime(index, klass) { + expect(this.primeOperations[index]).to.be.an.instanceof(klass) + return this + }, + + tap(fn) { + fn.call(this) + return this + }, + } + } + + // shorthand for creating an edit operation + function edit(pathname, textOperationJsonObject) { + return Operation.editFile( + pathname, + TextOperation.fromJSON(textOperationJsonObject) + ) + } + + it('transforms AddFile-AddFile with different names', function () { + runConcurrently(addFile('foo', ''), addFile('bar', 'a')) + .expectNoTransform() + .expectFiles({ bar: 'a', foo: '' }) + .expectSymmetry() + }) + + it('transforms AddFile-AddFile with same name', function () { + // the second file 'wins' + runConcurrently(addFile('foo', ''), addFile('foo', 'a')) + .expectFiles({ foo: 'a' }) + // if the first add was committed first, the second add overwrites it + .expectPrime(1, AddFileOperation) + // if the second add was committed first, the first add becomes a no-op + .expectPrime(0, NoOperation) + .swap() + .expectFiles({ foo: '' }) + }) + + it('transforms AddFile-MoveFile with no conflict', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + addFile('bar', 'a'), + makeOneFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ bar: 'a', baz: '' }) + .expectSymmetry() + }) + + it('transforms AddFile-MoveFile with move from new file', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + addFile('foo', 'a'), + makeOneFileSnapshot() + ) + .expectFiles({ baz: 'a' }) + // if the move was committed first, the add overwrites it + .expectPrime(1, AddFileOperation) + // if the add was committed first, the move appears in the history + .expectPrime(0, MoveFileOperation) + .expectSymmetry() + }) + + it('transforms AddFile-MoveFile with move to new file', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + addFile('baz', 'a'), + makeOneFileSnapshot() + ) + .expectFiles({ baz: 'a' }) + // if the move was committed first, the add overwrites it + .expectPrime(1, AddFileOperation) + // if the add was committed first, the move becomes a delete + .expectPrime(0, MoveFileOperation) + .tap(function () { + expect(this.primeOperations[0].isRemoveFile()).to.be.true + }) + .expectSymmetry() + }) + + it('transforms AddFile-RemoveFile with no conflict', function () { + runConcurrently( + Operation.removeFile('foo'), + addFile('bar', 'a'), + makeOneFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ bar: 'a' }) + .expectSymmetry() + }) + + it('transforms AddFile-RemoveFile that removes added file', function () { + runConcurrently( + Operation.removeFile('foo'), + addFile('foo', 'a'), + makeOneFileSnapshot() + ) + .expectFiles({ foo: 'a' }) + // if the move was committed first, the add overwrites it + .expectPrime(1, AddFileOperation) + // if the add was committed first, the move gets dropped + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms AddFile-EditFile with no conflict', function () { + runConcurrently( + edit('foo', ['x']), + addFile('bar', 'a'), + makeOneFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ bar: 'a', foo: 'x' }) + .expectSymmetry() + }) + + it('transforms AddFile-EditFile when new file is edited', function () { + runConcurrently( + edit('foo', ['x']), + addFile('foo', 'a'), + makeOneFileSnapshot() + ) + .expectFiles({ foo: 'a' }) + // if the edit was committed first, the add overwrites it + .expectPrime(1, AddFileOperation) + // if the add was committed first, the edit gets dropped + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms AddFile-SetFileMetadata with no conflict', function () { + const testMetadata = { baz: 1 } + runConcurrently( + addFile('bar', 'a'), + Operation.setFileMetadata('foo', testMetadata), + makeOneFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ foo: { content: '', metadata: testMetadata }, bar: 'a' }) + .expectSymmetry() + }) + + it('transforms AddFile-SetFileMetadata with same name', function () { + const testMetadata = { baz: 1 } + runConcurrently( + addFile('foo', 'x'), + Operation.setFileMetadata('foo', testMetadata), + makeEmptySnapshot() + ) + .expectFiles({ foo: { content: 'x', metadata: testMetadata } }) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile with no conflict', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + Operation.moveFile('bar', 'bat'), + makeTwoFileSnapshot() + ) + .expectFiles({ bat: 'a', baz: '' }) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile same move foo->foo, foo->foo', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.moveFile('foo', 'foo'), + makeOneFileSnapshot() + ) + .expectFiles({ foo: '' }) + .expectPrime(1, NoOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile no-op foo->foo, foo->bar', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.moveFile('foo', 'bar'), + makeOneFileSnapshot() + ) + .expectFiles({ bar: '' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile no-op foo->foo, bar->foo', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.moveFile('foo', 'bar'), + makeTwoFileSnapshot() + ) + .expectFiles({ bar: '' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile no-op foo->foo, bar->bar', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.moveFile('bar', 'bar'), + makeTwoFileSnapshot() + ) + .expectFiles({ bar: 'a', foo: '' }) + .expectPrime(1, NoOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile same move foo->bar, foo->bar', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.moveFile('foo', 'bar'), + makeOneFileSnapshot() + ) + .expectFiles({ bar: '' }) + .expectPrime(1, NoOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile opposite foo->bar, bar->foo', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.moveFile('bar', 'foo'), + makeTwoFileSnapshot() + ) + .expectFiles([]) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .tap(function () { + expect(this.primeOperations[1].isRemoveFile()).to.be.true + expect(this.primeOperations[1].getPathname()).to.equal('bar') + + expect(this.primeOperations[0].isRemoveFile()).to.be.true + expect(this.primeOperations[0].getPathname()).to.equal('foo') + }) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile no-op foo->foo, bar->baz', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.moveFile('bar', 'baz'), + makeTwoFileSnapshot() + ) + .expectFiles({ baz: 'a', foo: '' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile diverge foo->bar, foo->baz', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.moveFile('foo', 'baz'), + makeOneFileSnapshot() + ) + .expectFiles({ baz: '' }) + // if foo->bar was committed first, the second move becomes bar->baz + .expectPrime(1, MoveFileOperation) + // if foo->baz was committed first, the second move becomes a no-op + .expectPrime(0, NoOperation) + .tap(function () { + expect(this.primeOperations[1].getPathname()).to.equal('bar') + expect(this.primeOperations[1].getNewPathname()).to.equal('baz') + }) + .swap() + .expectFiles({ bar: '' }) + }) + + it('transforms MoveFile-MoveFile transitive foo->baz, bar->foo', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + Operation.moveFile('bar', 'foo'), + makeTwoFileSnapshot() + ) + .expectFiles({ baz: 'a' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile transitive foo->bar, bar->baz', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.moveFile('bar', 'baz'), + makeTwoFileSnapshot() + ) + .expectFiles({ baz: '' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-MoveFile converge foo->baz, bar->baz', function () { + runConcurrently( + Operation.moveFile('foo', 'baz'), + Operation.moveFile('bar', 'baz'), + makeTwoFileSnapshot() + ) + .expectFiles({ baz: 'a' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .tap(function () { + // if foo->baz was committed first, we just apply the move + expect(this.primeOperations[1]).to.eql(this.operations[1]) + + // if bar->baz was committed first, the other move becomes a remove + expect(this.primeOperations[0].isRemoveFile()).to.be.true + expect(this.primeOperations[0].getPathname()).to.equal('foo') + }) + .swap() + .expectFiles({ baz: '' }) + }) + + it('transforms MoveFile-RemoveFile no-op foo->foo, foo->', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.removeFile('foo'), + makeOneFileSnapshot() + ) + .expectFiles([]) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, NoOperation) + .tap(function () { + expect(this.primeOperations[1].isRemoveFile()).to.be.true + }) + .expectSymmetry() + }) + + it('transforms MoveFile-RemoveFile same move foo->, foo->', function () { + runConcurrently( + Operation.removeFile('foo'), + Operation.removeFile('foo'), + makeOneFileSnapshot() + ) + .expectFiles([]) + .expectPrime(1, NoOperation) + .expectPrime(0, NoOperation) + .expectSymmetry() + }) + + it('transforms MoveFile-RemoveFile no conflict foo->, bar->', function () { + runConcurrently( + Operation.removeFile('foo'), + Operation.removeFile('bar'), + makeTwoFileSnapshot() + ) + .expectFiles([]) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms MoveFile-RemoveFile no conflict foo->foo, bar->', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.removeFile('bar'), + makeTwoFileSnapshot() + ) + .expectFiles({ foo: '' }) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, NoOperation) + .tap(function () { + expect(this.primeOperations[1].isRemoveFile()).to.be.true + }) + .expectSymmetry() + }) + + it('transforms MoveFile-RemoveFile transitive foo->, bar->foo', function () { + runConcurrently( + Operation.removeFile('foo'), + Operation.moveFile('bar', 'foo'), + makeTwoFileSnapshot() + ) + .expectFiles([]) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .tap(function () { + expect(this.primeOperations[1].isRemoveFile()).to.be.true + expect(this.primeOperations[1].getPathname()).to.equal('bar') + + expect(this.primeOperations[0].isRemoveFile()).to.be.true + expect(this.primeOperations[0].getPathname()).to.equal('foo') + }) + .expectSymmetry() + }) + + it('transforms MoveFile-RemoveFile transitive foo->bar, bar->', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.removeFile('bar'), + makeTwoFileSnapshot() + ) + .expectFiles({}) + .expectPrime(1, MoveFileOperation) + .expectPrime(0, MoveFileOperation) + .tap(function () { + expect(this.primeOperations[1].isRemoveFile()).to.be.true + expect(this.primeOperations[1].getPathname()).to.equal('bar') + + expect(this.primeOperations[0].isRemoveFile()).to.be.true + expect(this.primeOperations[0].getPathname()).to.equal('foo') + }) + .expectSymmetry() + }) + + it('transforms MoveFile-EditFile with no conflict', function () { + runConcurrently( + Operation.moveFile('bar', 'baz'), + edit('foo', ['x']), + makeTwoFileSnapshot() + ) + .expectFiles({ baz: 'a', foo: 'x' }) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms MoveFile-EditFile with edit on pathname', function () { + runConcurrently( + Operation.moveFile('foo', 'bar'), + edit('foo', ['x']), + makeOneFileSnapshot() + ) + .expectFiles({ bar: 'x' }) + .expectPrime(1, EditFileOperation) + .expectPrime(0, MoveFileOperation) + .tap(function () { + expect(this.primeOperations[1].getPathname()).to.equal('bar') + + expect(this.primeOperations[0].getPathname()).to.equal('foo') + expect(this.primeOperations[0].getNewPathname()).to.equal('bar') + }) + .expectSymmetry() + }) + + it('transforms MoveFile-EditFile with edit on new pathname', function () { + runConcurrently( + Operation.moveFile('bar', 'foo'), + edit('foo', ['x']), + makeTwoFileSnapshot() + ) + .expectFiles({ foo: 'a' }) + .expectPrime(1, NoOperation) + .tap(function () { + expect(this.primeOperations[0]).to.eql(this.operations[0]) + }) + .expectSymmetry() + }) + + it('transforms MoveFile-EditFile with no-op move', function () { + runConcurrently( + Operation.moveFile('foo', 'foo'), + edit('foo', ['x']), + makeOneFileSnapshot() + ) + .expectFiles({ foo: 'x' }) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms MoveFile-SetFileMetadata with no conflict', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.moveFile('foo', 'baz'), + Operation.setFileMetadata('bar', testMetadata), + makeTwoFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ bar: { content: 'a', metadata: testMetadata }, baz: '' }) + .expectSymmetry() + }) + + it('transforms MoveFile-SetFileMetadata with set on pathname', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.setFileMetadata('foo', testMetadata), + makeOneFileSnapshot() + ) + .expectFiles({ bar: { content: '', metadata: testMetadata } }) + .expectSymmetry() + }) + + it('transforms MoveFile-SetFileMetadata w/ set on new pathname', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.moveFile('foo', 'bar'), + Operation.setFileMetadata('bar', testMetadata), + makeTwoFileSnapshot() + ) + // move wins + .expectFiles({ bar: { content: '', metadata: {} } }) + .expectSymmetry() + }) + + it('transforms MoveFile-SetFileMetadata with no-op move', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.moveFile('foo', 'foo'), + Operation.setFileMetadata('foo', testMetadata), + makeOneFileSnapshot() + ) + .expectFiles({ foo: { content: '', metadata: testMetadata } }) + .expectSymmetry() + }) + + it('transforms EditFile-EditFile with no conflict', function () { + runConcurrently( + edit('foo', ['x']), + edit('bar', [1, 'x']), + makeTwoFileSnapshot() + ) + .expectFiles({ bar: 'ax', foo: 'x' }) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms EditFile-EditFile on same file', function () { + runConcurrently( + edit('foo', ['x']), + edit('foo', ['y']), + makeOneFileSnapshot() + ) + .expectFiles({ foo: 'xy' }) + .expectPrime(1, EditFileOperation) + .expectPrime(0, EditFileOperation) + .tap(function () { + expect(this.primeOperations[1].getTextOperation().toJSON()).to.eql([ + 1, + 'y', + ]) + expect(this.primeOperations[0].getTextOperation().toJSON()).to.eql([ + 'x', + 1, + ]) + }) + .swap() + .expectFiles({ foo: 'yx' }) + }) + + it('transforms EditFile-RemoveFile with no conflict', function () { + runConcurrently( + edit('foo', ['x']), + Operation.removeFile('bar'), + makeTwoFileSnapshot() + ) + .expectFiles({ foo: 'x' }) + .expectNoTransform() + .expectSymmetry() + }) + + it('transforms EditFile-RemoveFile on same file', function () { + runConcurrently( + edit('foo', ['x']), + Operation.removeFile('foo'), + makeOneFileSnapshot() + ) + .expectFiles({}) + .expectSymmetry() + }) + + it('transforms EditFile-SetFileMetadata with no conflict', function () { + const testMetadata = { baz: 1 } + runConcurrently( + edit('foo', ['x']), + Operation.setFileMetadata('bar', testMetadata), + makeTwoFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ + foo: { content: 'x', metadata: {} }, + bar: { content: 'a', metadata: testMetadata }, + }) + .expectSymmetry() + }) + + it('transforms EditFile-SetFileMetadata on same file', function () { + const testMetadata = { baz: 1 } + runConcurrently( + edit('foo', ['x']), + Operation.setFileMetadata('foo', testMetadata), + makeOneFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ foo: { content: 'x', metadata: testMetadata } }) + .expectSymmetry() + }) + + it('transforms SetFileMetadata-SetFileMetadata w/ no conflict', function () { + runConcurrently( + Operation.setFileMetadata('foo', { baz: 1 }), + Operation.setFileMetadata('bar', { baz: 2 }), + makeTwoFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ + foo: { content: '', metadata: { baz: 1 } }, + bar: { content: 'a', metadata: { baz: 2 } }, + }) + .expectSymmetry() + }) + + it('transforms SetFileMetadata-SetFileMetadata on same file', function () { + runConcurrently( + Operation.setFileMetadata('foo', { baz: 1 }), + Operation.setFileMetadata('foo', { baz: 2 }), + makeOneFileSnapshot() + ) + // second op wins + .expectFiles({ foo: { content: '', metadata: { baz: 2 } } }) + .swap() + // first op wins + .expectFiles({ foo: { content: '', metadata: { baz: 1 } } }) + }) + + it('transforms SetFileMetadata-RemoveFile with no conflict', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.setFileMetadata('foo', testMetadata), + Operation.removeFile('bar'), + makeTwoFileSnapshot() + ) + .expectNoTransform() + .expectFiles({ foo: { content: '', metadata: testMetadata } }) + .expectSymmetry() + }) + + it('transforms SetFileMetadata-RemoveFile on same file', function () { + const testMetadata = { baz: 1 } + runConcurrently( + Operation.setFileMetadata('foo', testMetadata), + Operation.removeFile('foo'), + makeOneFileSnapshot() + ) + .expectFiles({}) + .expectSymmetry() + }) +}) diff --git a/libraries/overleaf-editor-core/test/safe_pathname.test.js b/libraries/overleaf-editor-core/test/safe_pathname.test.js new file mode 100644 index 0000000000..c123b20662 --- /dev/null +++ b/libraries/overleaf-editor-core/test/safe_pathname.test.js @@ -0,0 +1,113 @@ +'use strict' + +const { expect } = require('chai') +const ot = require('..') +const safePathname = ot.safePathname + +describe('safePathname', function () { + function expectClean(input, output) { + // check expected output and also idempotency + const cleanedInput = safePathname.clean(input) + expect(cleanedInput).to.equal(output) + expect(safePathname.clean(cleanedInput)).to.equal(cleanedInput) + expect(safePathname.isClean(cleanedInput)).to.be.true + } + + it('cleans pathnames', function () { + // preserve valid pathnames + expectClean('llama.jpg', 'llama.jpg') + expectClean('DSC4056.JPG', 'DSC4056.JPG') + + // detects unclean pathnames + expect(safePathname.isClean('rm -rf /')).to.be.falsy + + // replace invalid characters with underscores + expectClean('test-s*\u0001\u0002m\u0007st\u0008.jpg', 'test-s___m_st_.jpg') + + // keep slashes, normalize paths, replace .. + expectClean('./foo', 'foo') + expectClean('../foo', '__/foo') + expectClean('foo/./bar', 'foo/bar') + expectClean('foo/../bar', 'bar') + expectClean('../../tricky/foo.bar', '__/__/tricky/foo.bar') + expectClean('foo/../../tricky/foo.bar', '__/tricky/foo.bar') + expectClean('foo/bar/../../tricky/foo.bar', 'tricky/foo.bar') + expectClean('foo/bar/baz/../../tricky/foo.bar', 'foo/tricky/foo.bar') + + // remove illegal chars even when there is no extension + expectClean('**foo', '__foo') + + // remove windows file paths + expectClean('c:\\temp\\foo.txt', 'c:/temp/foo.txt') + + // do not allow a leading slash (relative paths only) + expectClean('/foo', '_/foo') + expectClean('//foo', '_/foo') + + // do not allow multiple leading slashes + expectClean('//foo', '_/foo') + + // do not allow a trailing slash + expectClean('/', '_') + expectClean('foo/', 'foo') + expectClean('foo.tex/', 'foo.tex') + + // do not allow multiple trailing slashes + expectClean('//', '_') + expectClean('///', '_') + expectClean('foo//', 'foo') + + // file and folder names that consist of . and .. are not OK + expectClean('.', '_') + expectClean('..', '__') + // we will allow name with more dots e.g. ... and .... + expectClean('...', '...') + expectClean('....', '....') + expectClean('foo/...', 'foo/...') + expectClean('foo/....', 'foo/....') + expectClean('foo/.../bar', 'foo/.../bar') + expectClean('foo/..../bar', 'foo/..../bar') + + // leading dots are OK + expectClean('._', '._') + expectClean('.gitignore', '.gitignore') + + // trailing dots are not OK on Windows but we allow them + expectClean('_.', '_.') + expectClean('foo/_.', 'foo/_.') + expectClean('foo/_./bar', 'foo/_./bar') + expectClean('foo/_../bar', 'foo/_../bar') + + // spaces are allowed + expectClean('a b.png', 'a b.png') + + // leading and trailing spaces are not OK + expectClean(' foo', 'foo') + expectClean(' foo', 'foo') + expectClean('foo ', 'foo') + expectClean('foo ', 'foo') + + // reserved file names on Windows should not be OK, but we already have + // some in the old system, so have to allow them for now + expectClean('AUX', 'AUX') + expectClean('foo/AUX', 'foo/AUX') + expectClean('AUX/foo', 'AUX/foo') + + // multiple dots are OK + expectClean('a.b.png', 'a.b.png') + expectClean('a.code.tex', 'a.code.tex') + + // there's no particular reason to allow multiple slashes; sometimes people + // seem to rename files to URLs (https://domain/path) in an attempt to + // upload a file, and this results in an empty directory name + expectClean('foo//bar.png', 'foo/bar.png') + expectClean('foo///bar.png', 'foo/bar.png') + + // Check javascript property handling + expectClean('foo/prototype', 'foo/prototype') // OK as part of a pathname + expectClean('prototype/test.txt', 'prototype/test.txt') + expectClean('prototype', '@prototype') // not OK as whole pathname + expectClean('hasOwnProperty', '@hasOwnProperty') + expectClean('**proto**', '@__proto__') + }) +}) diff --git a/libraries/overleaf-editor-core/test/snapshot.test.js b/libraries/overleaf-editor-core/test/snapshot.test.js new file mode 100644 index 0000000000..f7979952e6 --- /dev/null +++ b/libraries/overleaf-editor-core/test/snapshot.test.js @@ -0,0 +1,92 @@ +'use strict' + +const { expect } = require('chai') +const { + File, + Snapshot, + TextOperation, + Change, + EditFileOperation, +} = require('..') + +describe('Snapshot', function () { + describe('findBlobHashes', function () { + it('finds blob hashes from files', function () { + const snapshot = new Snapshot() + + const blobHashes = new Set() + snapshot.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(0) + + // Add a file without a hash. + snapshot.addFile('foo', File.fromString('')) + snapshot.findBlobHashes(blobHashes) + expect(blobHashes.size).to.equal(0) + + // Add a file with a hash. + snapshot.addFile('bar', File.fromHash(File.EMPTY_FILE_HASH)) + snapshot.findBlobHashes(blobHashes) + expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH]) + }) + }) + + describe('editFile', function () { + let snapshot + let operation + + beforeEach(function () { + snapshot = new Snapshot() + snapshot.addFile('hello.txt', File.fromString('hello')) + operation = new TextOperation() + operation.retain(5) + operation.insert(' world!') + }) + + it('applies text operations to the file', function () { + snapshot.editFile('hello.txt', operation) + const file = snapshot.getFile('hello.txt') + expect(file.getContent()).to.equal('hello world!') + }) + + it('rejects text operations for nonexistent file', function () { + expect(() => { + snapshot.editFile('does-not-exist.txt', operation) + }).to.throw(Snapshot.EditMissingFileError) + }) + }) + + describe('applyAll', function () { + let snapshot + let change + + beforeEach(function () { + snapshot = new Snapshot() + snapshot.addFile('empty.txt', File.fromString('')) + const badTextOp = new TextOperation() + badTextOp.insert('FAIL!') + const goodTextOp = new TextOperation() + goodTextOp.insert('SUCCESS!') + change = new Change( + [ + new EditFileOperation('missing.txt', badTextOp), + new EditFileOperation('empty.txt', goodTextOp), + ], + new Date() + ) + }) + + it('ignores recoverable errors', function () { + snapshot.applyAll([change]) + const file = snapshot.getFile('empty.txt') + expect(file.getContent()).to.equal('SUCCESS!') + }) + + it('stops on recoverable errors in strict mode', function () { + expect(() => { + snapshot.applyAll([change], { strict: true }) + }).to.throw(Snapshot.EditMissingFileError) + const file = snapshot.getFile('empty.txt') + expect(file.getContent()).to.equal('') + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/string_file_data.test.js b/libraries/overleaf-editor-core/test/string_file_data.test.js new file mode 100644 index 0000000000..566c838924 --- /dev/null +++ b/libraries/overleaf-editor-core/test/string_file_data.test.js @@ -0,0 +1,37 @@ +'use strict' + +const { expect } = require('chai') +const _ = require('lodash') + +const ot = require('..') +const StringFileData = require('../lib/file_data/string_file_data') +const TextOperation = ot.TextOperation + +describe('StringFileData', function () { + it('throws when it contains non BMP chars', function () { + const content = '𝌆𝌆𝌆' + const fileData = new StringFileData(content) + const operation = new TextOperation() + operation.insert('aa') + expect(() => { + fileData.edit(operation) + }).to.throw(TextOperation.ApplyError, /string contains non BMP characters/) + }) + + it('validates string length when edited', function () { + const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH) + const fileData = new StringFileData(longString) + expect(fileData.getByteLength()).to.equal(longString.length) + expect(fileData.getStringLength()).to.equal(longString.length) + + expect(() => { + fileData.edit(new TextOperation().retain(longString.length).insert('x')) + }).to.throw(TextOperation.TooLongError) + expect(fileData.getByteLength()).to.equal(longString.length) + expect(fileData.getStringLength()).to.equal(longString.length) + + fileData.edit(new TextOperation().retain(longString.length - 1).remove(1)) + expect(fileData.getByteLength()).to.equal(longString.length - 1) + expect(fileData.getStringLength()).to.equal(longString.length - 1) + }) +}) diff --git a/libraries/overleaf-editor-core/test/support/fake_blob_store.js b/libraries/overleaf-editor-core/test/support/fake_blob_store.js new file mode 100644 index 0000000000..580f3aa107 --- /dev/null +++ b/libraries/overleaf-editor-core/test/support/fake_blob_store.js @@ -0,0 +1,35 @@ +/** + * @typedef {import("../..").Blob } Blob + */ + +/** + * @template T + * @typedef {import("bluebird")} BPromise + */ + +/** + * Fake blob store for tests + */ +class FakeBlobStore { + /** + * Get a string from the blob store + * + * @param {string} hash + * @return {BPromise} + */ + getString(hash) { + throw new Error('Not implemented') + } + + /** + * Store a string in the blob store + * + * @param {string} content + * @return {BPromise} + */ + putString(content) { + throw new Error('Not implemented') + } +} + +module.exports = FakeBlobStore diff --git a/libraries/overleaf-editor-core/test/support/random.js b/libraries/overleaf-editor-core/test/support/random.js new file mode 100644 index 0000000000..0a87a5f4f9 --- /dev/null +++ b/libraries/overleaf-editor-core/test/support/random.js @@ -0,0 +1,38 @@ +// +// Randomised testing helpers from OT.js: +// https://github.com/Operational-Transformation/ot.js/blob/ +// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/helpers.js +// +'use strict' + +function randomInt(n) { + return Math.floor(Math.random() * n) +} + +function randomString(n) { + let str = '' + while (n--) { + if (Math.random() < 0.15) { + str += '\n' + } else { + const chr = randomInt(26) + 97 + str += String.fromCharCode(chr) + } + } + return str +} + +function randomElement(arr) { + return arr[randomInt(arr.length)] +} + +function randomTest(numTrials, test) { + return function () { + while (numTrials--) test() + } +} + +exports.int = randomInt +exports.string = randomString +exports.element = randomElement +exports.test = randomTest diff --git a/libraries/overleaf-editor-core/test/text_operation.test.js b/libraries/overleaf-editor-core/test/text_operation.test.js new file mode 100644 index 0000000000..a0bee0b53f --- /dev/null +++ b/libraries/overleaf-editor-core/test/text_operation.test.js @@ -0,0 +1,269 @@ +// +// These tests are based on the OT.js tests: +// https://github.com/Operational-Transformation/ot.js/blob/ +// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js +// +'use strict' + +const { expect } = require('chai') +const random = require('./support/random') + +const ot = require('..') +const TextOperation = ot.TextOperation + +function randomOperation(str) { + const operation = new TextOperation() + let left + while (true) { + left = str.length - operation.baseLength + if (left === 0) break + const r = Math.random() + const l = 1 + random.int(Math.min(left - 1, 20)) + if (r < 0.2) { + operation.insert(random.string(l)) + } else if (r < 0.4) { + operation.remove(l) + } else { + operation.retain(l) + } + } + if (Math.random() < 0.3) { + operation.insert(1 + random.string(10)) + } + return operation +} + +describe('TextOperation', function () { + const numTrials = 500 + + it('tracks base and target lengths', function () { + const o = new TextOperation() + expect(o.baseLength).to.equal(0) + expect(o.targetLength).to.equal(0) + o.retain(5) + expect(o.baseLength).to.equal(5) + expect(o.targetLength).to.equal(5) + o.insert('abc') + expect(o.baseLength).to.equal(5) + expect(o.targetLength).to.equal(8) + o.retain(2) + expect(o.baseLength).to.equal(7) + expect(o.targetLength).to.equal(10) + o.remove(2) + expect(o.baseLength).to.equal(9) + expect(o.targetLength).to.equal(10) + }) + + it('supports chaining', function () { + const o = new TextOperation() + .retain(5) + .retain(0) + .insert('lorem') + .insert('') + .remove('abc') + .remove(3) + .remove(0) + .remove('') + expect(o.ops.length).to.equal(3) + }) + + it('ignores empty operations', function () { + const o = new TextOperation() + o.retain(0) + o.insert('') + o.remove('') + expect(o.ops.length).to.equal(0) + }) + + it('checks for equality', function () { + const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3) + const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5) + expect(op1.equals(op2)).to.be.true + op1.remove(1) + op2.retain(1) + expect(op1.equals(op2)).to.be.false + }) + + it('merges ops', function () { + function last(arr) { + return arr[arr.length - 1] + } + const o = new TextOperation() + expect(o.ops.length).to.equal(0) + o.retain(2) + expect(o.ops.length).to.equal(1) + expect(last(o.ops)).to.equal(2) + o.retain(3) + expect(o.ops.length).to.equal(1) + expect(last(o.ops)).to.equal(5) + o.insert('abc') + expect(o.ops.length).to.equal(2) + expect(last(o.ops)).to.equal('abc') + o.insert('xyz') + expect(o.ops.length).to.equal(2) + expect(last(o.ops)).to.equal('abcxyz') + o.remove('d') + expect(o.ops.length).to.equal(3) + expect(last(o.ops)).to.equal(-1) + o.remove('d') + expect(o.ops.length).to.equal(3) + expect(last(o.ops)).to.equal(-2) + }) + + it('checks for no-ops', function () { + const o = new TextOperation() + expect(o.isNoop()).to.be.true + o.retain(5) + expect(o.isNoop()).to.be.true + o.retain(3) + expect(o.isNoop()).to.be.true + o.insert('lorem') + expect(o.isNoop()).to.be.false + }) + + it('converts to string', function () { + const o = new TextOperation() + o.retain(2) + o.insert('lorem') + o.remove('ipsum') + o.retain(5) + expect(o.toString()).to.equal( + "retain 2, insert 'lorem', remove 5, retain 5" + ) + }) + + it('converts from JSON', function () { + const ops = [2, -1, -1, 'cde'] + const o = TextOperation.fromJSON(ops) + expect(o.ops.length).to.equal(3) + expect(o.baseLength).to.equal(4) + expect(o.targetLength).to.equal(5) + + function assertIncorrectAfter(fn) { + const ops2 = ops.slice(0) + fn(ops2) + expect(() => { + TextOperation.fromJSON(ops2) + }).to.throw + } + + assertIncorrectAfter(ops2 => { + ops2.push({ insert: 'x' }) + }) + assertIncorrectAfter(ops2 => { + ops2.push(null) + }) + }) + + it( + 'applies (randomised)', + random.test(numTrials, () => { + const str = random.string(50) + const o = randomOperation(str) + expect(str.length).to.equal(o.baseLength) + expect(o.apply(str).length).to.equal(o.targetLength) + }) + ) + + it( + 'inverts (randomised)', + random.test(numTrials, () => { + const str = random.string(50) + const o = randomOperation(str) + const p = o.invert(str) + expect(o.baseLength).to.equal(p.targetLength) + expect(o.targetLength).to.equal(p.baseLength) + expect(p.apply(o.apply(str))).to.equal(str) + }) + ) + + it( + 'converts to/from JSON (randomised)', + random.test(numTrials, () => { + const doc = random.string(50) + const operation = randomOperation(doc) + const roundTripOperation = TextOperation.fromJSON(operation.toJSON()) + expect(operation.equals(roundTripOperation)).to.be.true + }) + ) + + it( + 'composes (randomised)', + random.test(numTrials, () => { + // invariant: apply(str, compose(a, b)) === apply(apply(str, a), b) + const str = random.string(20) + const a = randomOperation(str) + const afterA = a.apply(str) + expect(afterA.length).to.equal(a.targetLength) + const b = randomOperation(afterA) + const afterB = b.apply(afterA) + expect(afterB.length).to.equal(b.targetLength) + const ab = a.compose(b) + expect(ab.targetLength).to.equal(b.targetLength) + const afterAB = ab.apply(str) + expect(afterAB).to.equal(afterB) + }) + ) + + it( + 'transforms (randomised)', + random.test(numTrials, () => { + // invariant: compose(a, b') = compose(b, a') + // where (a', b') = transform(a, b) + const str = random.string(20) + const a = randomOperation(str) + const b = randomOperation(str) + const primes = TextOperation.transform(a, b) + const aPrime = primes[0] + const bPrime = primes[1] + const abPrime = a.compose(bPrime) + const baPrime = b.compose(aPrime) + const afterAbPrime = abPrime.apply(str) + const afterBaPrime = baPrime.apply(str) + expect(abPrime.equals(baPrime)).to.be.true + expect(afterAbPrime).to.equal(afterBaPrime) + }) + ) + + it('throws when invalid operations are applied', function () { + const operation = new TextOperation().retain(1) + expect(() => { + operation.apply('') + }).to.throw(TextOperation.ApplyError) + expect(() => { + operation.apply(' ') + }).not.to.throw + }) + + it('throws when insert text contains non BMP chars', function () { + const operation = new TextOperation() + const str = '𝌆\n' + expect(() => { + operation.insert(str) + }).to.throw( + TextOperation.UnprocessableError, + /inserted text contains non BMP characters/ + ) + }) + + it('throws when base string contains non BMP chars', function () { + const operation = new TextOperation() + const str = '𝌆\n' + expect(() => { + operation.apply(str) + }).to.throw( + TextOperation.UnprocessableError, + /string contains non BMP characters/ + ) + }) + + it('throws at from JSON when it contains non BMP chars', function () { + const operation = ['𝌆\n'] + expect(() => { + TextOperation.fromJSON(operation) + }).to.throw( + TextOperation.UnprocessableError, + /inserted text contains non BMP characters/ + ) + }) +}) diff --git a/libraries/overleaf-editor-core/tsconfig.json b/libraries/overleaf-editor-core/tsconfig.json new file mode 100644 index 0000000000..536cb6e9a5 --- /dev/null +++ b/libraries/overleaf-editor-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "checkJs": true, + "esModuleInterop": true, + "lib": ["es2018"], + "module": "commonjs", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["lib/**/*", "typings/**/*"] +} diff --git a/services/history-v1/.gitignore b/services/history-v1/.gitignore new file mode 100644 index 0000000000..edb0f85350 --- /dev/null +++ b/services/history-v1/.gitignore @@ -0,0 +1,3 @@ + +# managed by monorepo$ bin/update_build_scripts +.npmrc diff --git a/services/history-v1/.mocharc.json b/services/history-v1/.mocharc.json new file mode 100644 index 0000000000..dc3280aa96 --- /dev/null +++ b/services/history-v1/.mocharc.json @@ -0,0 +1,3 @@ +{ + "require": "test/setup.js" +} diff --git a/services/history-v1/.nvmrc b/services/history-v1/.nvmrc new file mode 100644 index 0000000000..c85fa1bbef --- /dev/null +++ b/services/history-v1/.nvmrc @@ -0,0 +1 @@ +16.17.1 diff --git a/services/history-v1/Dockerfile b/services/history-v1/Dockerfile new file mode 100644 index 0000000000..bd5bcaf3bc --- /dev/null +++ b/services/history-v1/Dockerfile @@ -0,0 +1,26 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +FROM node:16.17.1 as base + +WORKDIR /overleaf/services/history-v1 + +# Google Cloud Storage needs a writable $HOME/.config for resumable uploads +# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) +RUN mkdir /home/node/.config && chown node:node /home/node/.config + +FROM base as app + +COPY package.json package-lock.json /overleaf/ +COPY services/history-v1/package.json /overleaf/services/history-v1/ +COPY libraries/ /overleaf/libraries/ + +RUN cd /overleaf && npm ci --quiet + +COPY services/history-v1/ /overleaf/services/history-v1/ + +FROM app +USER node + +CMD ["node", "--expose-gc", "app.js"] diff --git a/services/history-v1/Makefile b/services/history-v1/Makefile new file mode 100644 index 0000000000..88dcfdd09e --- /dev/null +++ b/services/history-v1/Makefile @@ -0,0 +1,103 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = history-v1 +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') + +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ + BRANCH_NAME=$(BRANCH_NAME) \ + PROJECT_NAME=$(PROJECT_NAME) \ + MOCHA_GREP=${MOCHA_GREP} \ + docker-compose ${DOCKER_COMPOSE_FLAGS} + +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=test_acceptance_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=test_unit_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +clean: + -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local + -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local + +HERE=$(shell pwd) +MONOREPO=$(shell cd ../../ && pwd) +# Run the linting commands in the scope of the monorepo. +# Eslint and prettier (plus some configs) are on the root. +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:16.17.1 npm run --silent + +format: + $(RUN_LINTING) format + +format_fix: + $(RUN_LINTING) format:fix + +lint: + $(RUN_LINTING) lint + +lint_fix: + $(RUN_LINTING) lint:fix + +test: format lint test_unit test_acceptance + +test_unit: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit + $(MAKE) test_unit_clean +endif + +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif + +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean + +test_acceptance_run: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif + +test_acceptance_run_debug: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif + +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 + +test_acceptance_pre_run: +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif + +benchmarks: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance npm run benchmarks + +build: + docker build --pull --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --file Dockerfile \ + ../.. + +tar: + $(DOCKER_COMPOSE) up tar + +publish: + + docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + + +.PHONY: clean test test_unit test_acceptance test_clean benchmarks build publish diff --git a/services/history-v1/README.md b/services/history-v1/README.md new file mode 100644 index 0000000000..9591159ff0 --- /dev/null +++ b/services/history-v1/README.md @@ -0,0 +1,51 @@ +## Database migrations + +The history service uses knex to manage PostgreSQL migrations. + +To create a new migrations, run: +``` +npx knex migrate:make migration_name +``` + +To apply migrations, run: +``` +npx knex migrate:latest +``` + +For more information, consult the [knex migrations +guide](https://knexjs.org/guide/migrations.html#migration-cli). + +## Global blobs + +Global blobs are blobs that are shared between projects. The list of global +blobs is stored in the projectHistoryGlobalBlobs Mongo collection and is read +when the service starts. Changing the list of global blobs needs to be done +carefully. + +### Adding a blob to the global blobs list + +If we identify a blob that appears in many projects, we might want to move that +blob to the global blobs list. + +1. Add a record for the blob to the projectHistoryGlobalBlobs collection. +2. Restart the history service. +3. Delete any corresponding project blobs. + +### Removing a blob from the global blobs list + +Removing a blob from the global blobs list is trickier. As soon as the global +blob is made unavailable, every project that needs the blob will have to get +its own copy. To avoid disruptions, follow these steps: + +1. In the projectHistoryGlobalBlobs collection, set the `demoted` property to + `false` on the global blob to remove. This will make the history system + write new instances of this blob to project blobs, but still read from the + global blob. + +2. Restart the history service. + +3. Copy the blob to all projects that need it. + +4. Remove the blob from the projectHistoryGlobalBlobs collection. + +5. Restart the history service. diff --git a/services/history-v1/api/app/security.js b/services/history-v1/api/app/security.js new file mode 100644 index 0000000000..c82c3d2683 --- /dev/null +++ b/services/history-v1/api/app/security.js @@ -0,0 +1,147 @@ +'use strict' + +const basicAuth = require('basic-auth') +const config = require('config') +const HTTPStatus = require('http-status') +const jwt = require('jsonwebtoken') +const tsscmp = require('tsscmp') + +function setupBasicHttpAuthForSwaggerDocs(app) { + app.use('/docs', function (req, res, next) { + if (hasValidBasicAuthCredentials(req)) { + return next() + } + + res.header('WWW-Authenticate', 'Basic realm="Application"') + res.status(HTTPStatus.UNAUTHORIZED).end() + }) +} + +exports.setupBasicHttpAuthForSwaggerDocs = setupBasicHttpAuthForSwaggerDocs + +function hasValidBasicAuthCredentials(req) { + const credentials = basicAuth(req) + if (!credentials) return false + + // No security in the name, so just use straight comparison. + if (credentials.name !== 'staging') return false + + const password = config.get('basicHttpAuth.password') + if (password && tsscmp(credentials.pass, password)) return true + + // Support an old password so we can change the password without downtime. + if (config.has('basicHttpAuth.oldPassword')) { + const oldPassword = config.get('basicHttpAuth.oldPassword') + if (oldPassword && tsscmp(credentials.pass, oldPassword)) return true + } + + return false +} + +function setupSSL(app) { + const httpsOnly = config.get('httpsOnly') === 'true' + if (!httpsOnly) { + return + } + app.enable('trust proxy') + app.use(function (req, res, next) { + if (req.protocol === 'https') { + next() + return + } + if (req.method === 'GET' || req.method === 'HEAD') { + res.redirect('https://' + req.headers.host + req.url) + } else { + res + .status(HTTPStatus.FORBIDDEN) + .send('Please use HTTPS when submitting data to this server.') + } + }) +} + +exports.setupSSL = setupSSL + +function handleJWTAuth(req, authOrSecDef, scopesOrApiKey, next) { + // as a temporary solution, to make the OT demo still work + // this handler will also check for basic authorization + if (hasValidBasicAuthCredentials(req)) { + return next() + } + let token, err + if (authOrSecDef.name === 'token') { + token = req.query.token + } else if ( + req.headers.authorization && + req.headers.authorization.split(' ')[0] === 'Bearer' + ) { + token = req.headers.authorization.split(' ')[1] + } + if (!token) { + err = new Error('jwt missing') + err.statusCode = HTTPStatus.UNAUTHORIZED + err.headers = { 'WWW-Authenticate': 'Bearer' } + return next(err) + } + let decoded + try { + decoded = decodeJWT(token) + } catch (error) { + if ( + error instanceof jwt.JsonWebTokenError || + error instanceof jwt.TokenExpiredError + ) { + err = new Error(error.message) + err.statusCode = HTTPStatus.UNAUTHORIZED + err.headers = { 'WWW-Authenticate': 'Bearer error="invalid_token"' } + return next(err) + } + throw error + } + if (decoded.project_id.toString() !== req.swagger.params.project_id.value) { + err = new Error('Wrong project_id') + err.statusCode = HTTPStatus.FORBIDDEN + return next(err) + } + next() +} + +/** + * Verify and decode the given JSON Web Token + */ +function decodeJWT(token) { + const key = config.get('jwtAuth.key') + const algorithm = config.get('jwtAuth.algorithm') + try { + return jwt.verify(token, key, { algorithms: [algorithm] }) + } catch (err) { + // Support an old key so we can change the key without downtime. + if (config.has('jwtAuth.oldKey')) { + const oldKey = config.get('jwtAuth.oldKey') + return jwt.verify(token, oldKey, { algorithms: [algorithm] }) + } else { + throw err + } + } +} +function handleBasicAuth(req, authOrSecDef, scopesOrApiKey, next) { + if (hasValidBasicAuthCredentials(req)) { + return next() + } + const error = new Error() + error.statusCode = HTTPStatus.UNAUTHORIZED + error.headers = { 'WWW-Authenticate': 'Basic realm="Application"' } + return next(error) +} + +function getSwaggerHandlers() { + const handlers = {} + if (!config.has('jwtAuth.key') || !config.has('basicHttpAuth.password')) { + throw new Error('missing authentication env vars') + } + handlers.jwt = handleJWTAuth + handlers.basic = handleBasicAuth + handlers.token = handleJWTAuth + return handlers +} + +exports.getSwaggerHandlers = getSwaggerHandlers diff --git a/services/history-v1/api/controllers/expressify.js b/services/history-v1/api/controllers/expressify.js new file mode 100644 index 0000000000..5eee15fbe6 --- /dev/null +++ b/services/history-v1/api/controllers/expressify.js @@ -0,0 +1,10 @@ +/** + * Turn an async function into an Express middleware + */ +function expressify(fn) { + return (req, res, next) => { + fn(req, res, next).catch(next) + } +} + +module.exports = expressify diff --git a/services/history-v1/api/controllers/health_checks.js b/services/history-v1/api/controllers/health_checks.js new file mode 100644 index 0000000000..e9f7176b9f --- /dev/null +++ b/services/history-v1/api/controllers/health_checks.js @@ -0,0 +1,23 @@ +const logger = require('@overleaf/logger') +const expressify = require('./expressify') +const { mongodb } = require('../../storage') + +async function status(req, res) { + try { + await mongodb.db.command({ ping: 1 }) + } catch (err) { + logger.warn({ err }, 'Lost connection with MongoDB') + res.status(500).send('Lost connection with MongoDB') + return + } + res.send('history-v1 is up') +} + +function healthCheck(req, res) { + res.send('OK') +} + +module.exports = { + status: expressify(status), + healthCheck, +} diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js new file mode 100644 index 0000000000..ec4aa317b0 --- /dev/null +++ b/services/history-v1/api/controllers/project_import.js @@ -0,0 +1,140 @@ +'use strict' + +const BPromise = require('bluebird') +const HTTPStatus = require('http-status') + +const core = require('overleaf-editor-core') +const Change = core.Change +const Chunk = core.Chunk +const File = core.File +const FileMap = core.FileMap +const Snapshot = core.Snapshot +const TextOperation = core.TextOperation + +const logger = require('@overleaf/logger') + +const storage = require('../../storage') +const BatchBlobStore = storage.BatchBlobStore +const BlobStore = storage.BlobStore +const chunkStore = storage.chunkStore +const HashCheckBlobStore = storage.HashCheckBlobStore +const persistChanges = storage.persistChanges + +const render = require('./render') + +exports.importSnapshot = function importSnapshot(req, res, next) { + const projectId = req.swagger.params.project_id.value + const rawSnapshot = req.swagger.params.snapshot.value + + let snapshot + + try { + snapshot = Snapshot.fromRaw(rawSnapshot) + } catch (err) { + return render.unprocessableEntity(res) + } + + return chunkStore + .initializeProject(projectId, snapshot) + .then(function (projectId) { + res.status(HTTPStatus.OK).json({ projectId }) + }) + .catch(err => { + if (err instanceof chunkStore.AlreadyInitialized) { + render.conflict(res) + } else { + next(err) + } + }) +} + +exports.importChanges = function importChanges(req, res, next) { + const projectId = req.swagger.params.project_id.value + const rawChanges = req.swagger.params.changes.value + const endVersion = req.swagger.params.end_version.value + const returnSnapshot = req.swagger.params.return_snapshot.value || 'none' + + let changes + + try { + changes = rawChanges.map(Change.fromRaw) + } catch (err) { + logger.error(err) + return render.unprocessableEntity(res) + } + + // Set limits to force us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + maxChanges: 0, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + const hashCheckBlobStore = new HashCheckBlobStore(blobStore) + + function loadFiles() { + const blobHashes = new Set() + changes.forEach(function findBlobHashesToPreload(change) { + change.findBlobHashes(blobHashes) + }) + + function lazyLoadChangeFiles(change) { + return change.loadFiles('lazy', batchBlobStore) + } + + return batchBlobStore + .preload(Array.from(blobHashes)) + .then(function lazyLoadChangeFilesWithBatching() { + return BPromise.each(changes, lazyLoadChangeFiles) + }) + } + + function buildResultSnapshot(resultChunk) { + return BPromise.resolve( + resultChunk || chunkStore.loadLatest(projectId) + ).then(function (chunk) { + const snapshot = chunk.getSnapshot() + snapshot.applyAll(chunk.getChanges()) + return snapshot.store(hashCheckBlobStore) + }) + } + + return loadFiles() + .then(function () { + return persistChanges(projectId, changes, limits, endVersion) + }) + .then(function (result) { + if (returnSnapshot === 'none') { + res.status(HTTPStatus.CREATED).json({}) + } else { + return buildResultSnapshot(result && result.currentChunk).then( + function (rawSnapshot) { + res.status(HTTPStatus.CREATED).json(rawSnapshot) + } + ) + } + }) + .catch(err => { + if ( + err instanceof Chunk.ConflictingEndVersion || + err instanceof TextOperation.UnprocessableError || + err instanceof File.NotEditableError || + err instanceof FileMap.PathnameError || + err instanceof Snapshot.EditMissingFileError || + err instanceof chunkStore.ChunkVersionConflictError + ) { + // If we failed to apply operations, that's probably because they were + // invalid. + logger.error(err) + render.unprocessableEntity(res) + } else if (err instanceof Chunk.NotFoundError) { + render.notFound(res) + } else { + next(err) + } + }) +} diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js new file mode 100644 index 0000000000..b83fc4a0e9 --- /dev/null +++ b/services/history-v1/api/controllers/projects.js @@ -0,0 +1,235 @@ +'use strict' + +const _ = require('lodash') +const Path = require('path') +const Stream = require('stream') +const HTTPStatus = require('http-status') +const fs = require('fs') +const { promisify } = require('util') +const config = require('config') + +const logger = require('@overleaf/logger') +const { Chunk, ChunkResponse, Blob } = require('overleaf-editor-core') +const { + BlobStore, + blobHash, + chunkStore, + HashCheckBlobStore, + ProjectArchive, + zipStore, +} = require('../../storage') + +const render = require('./render') +const expressify = require('./expressify') +const withTmpDir = require('./with_tmp_dir') +const StreamSizeLimit = require('./stream_size_limit') + +const pipeline = promisify(Stream.pipeline) + +async function initializeProject(req, res, next) { + let projectId = req.swagger.params.body.value.projectId + try { + projectId = await chunkStore.initializeProject(projectId) + res.status(HTTPStatus.OK).json({ projectId }) + } catch (err) { + if (err instanceof chunkStore.AlreadyInitialized) { + render.conflict(res) + } else { + throw err + } + } +} + +async function getLatestContent(req, res, next) { + const projectId = req.swagger.params.project_id.value + const blobStore = new BlobStore(projectId) + const chunk = await chunkStore.loadLatest(projectId) + const snapshot = chunk.getSnapshot() + snapshot.applyAll(chunk.getChanges()) + await snapshot.loadFiles('eager', blobStore) + res.json(snapshot.toRaw()) +} + +async function getLatestHashedContent(req, res, next) { + const projectId = req.swagger.params.project_id.value + const blobStore = new HashCheckBlobStore(new BlobStore(projectId)) + const chunk = await chunkStore.loadLatest(projectId) + const snapshot = chunk.getSnapshot() + snapshot.applyAll(chunk.getChanges()) + await snapshot.loadFiles('eager', blobStore) + const rawSnapshot = await snapshot.store(blobStore) + res.json(rawSnapshot) +} + +async function getLatestHistory(req, res, next) { + const projectId = req.swagger.params.project_id.value + try { + const chunk = await chunkStore.loadLatest(projectId) + const chunkResponse = new ChunkResponse(chunk) + res.json(chunkResponse.toRaw()) + } catch (err) { + if (err instanceof Chunk.NotFoundError) { + render.notFound(res) + } else { + throw err + } + } +} + +async function getHistory(req, res, next) { + const projectId = req.swagger.params.project_id.value + const version = req.swagger.params.version.value + try { + const chunk = await chunkStore.loadAtVersion(projectId, version) + const chunkResponse = new ChunkResponse(chunk) + res.json(chunkResponse.toRaw()) + } catch (err) { + if (err instanceof Chunk.NotFoundError) { + render.notFound(res) + } else { + throw err + } + } +} + +async function getHistoryBefore(req, res, next) { + const projectId = req.swagger.params.project_id.value + const timestamp = req.swagger.params.timestamp.value + try { + const chunk = await chunkStore.loadAtTimestamp(projectId, timestamp) + const chunkResponse = new ChunkResponse(chunk) + res.json(chunkResponse.toRaw()) + } catch (err) { + if (err instanceof Chunk.NotFoundError) { + render.notFound(res) + } else { + throw err + } + } +} + +async function getZip(req, res, next) { + const projectId = req.swagger.params.project_id.value + const version = req.swagger.params.version.value + const blobStore = new BlobStore(projectId) + + let snapshot + try { + snapshot = await getSnapshotAtVersion(projectId, version) + } catch (err) { + if (err instanceof Chunk.NotFoundError) { + return render.notFound(res) + } else { + throw err + } + } + + await withTmpDir('get-zip-', async tmpDir => { + const tmpFilename = Path.join(tmpDir, 'project.zip') + const archive = new ProjectArchive(snapshot) + await archive.writeZip(blobStore, tmpFilename) + res.set('Content-Type', 'application/octet-stream') + res.set('Content-Disposition', 'attachment; filename=project.zip') + const stream = fs.createReadStream(tmpFilename) + await pipeline(stream, res) + }) +} + +async function createZip(req, res, next) { + const projectId = req.swagger.params.project_id.value + const version = req.swagger.params.version.value + try { + const snapshot = await getSnapshotAtVersion(projectId, version) + const zipUrl = await zipStore.getSignedUrl(projectId, version) + // Do not await this; run it in the background. + zipStore.storeZip(projectId, version, snapshot).catch(err => { + logger.error({ err, projectId, version }, 'createZip: storeZip failed') + }) + res.status(HTTPStatus.OK).json({ zipUrl }) + } catch (error) { + if (error instanceof Chunk.NotFoundError) { + render.notFound(res) + } else { + next(error) + } + } +} + +async function deleteProject(req, res, next) { + const projectId = req.swagger.params.project_id.value + const blobStore = new BlobStore(projectId) + await Promise.all([ + chunkStore.deleteProjectChunks(projectId), + blobStore.deleteBlobs(), + ]) + res.status(HTTPStatus.NO_CONTENT).send() +} + +async function createProjectBlob(req, res, next) { + const projectId = req.swagger.params.project_id.value + const expectedHash = req.swagger.params.hash.value + const maxUploadSize = parseInt(config.get('maxFileUploadSize'), 10) + + await withTmpDir('blob-', async tmpDir => { + const tmpPath = Path.join(tmpDir, 'content') + const sizeLimit = new StreamSizeLimit(maxUploadSize) + await pipeline(req, sizeLimit, fs.createWriteStream(tmpPath)) + if (sizeLimit.sizeLimitExceeded) { + return render.requestEntityTooLarge(res) + } + const hash = await blobHash.fromFile(tmpPath) + if (hash !== expectedHash) { + logger.debug({ hash, expectedHash }, 'Hash mismatch') + return render.conflict(res, 'File hash mismatch') + } + + const blobStore = new BlobStore(projectId) + await blobStore.putFile(tmpPath) + res.status(HTTPStatus.CREATED).end() + }) +} + +async function getProjectBlob(req, res, next) { + const projectId = req.swagger.params.project_id.value + const hash = req.swagger.params.hash.value + + const blobStore = new BlobStore(projectId) + let stream + try { + stream = await blobStore.getStream(hash) + } catch (err) { + if (err instanceof Blob.NotFoundError) { + return render.notFound(res) + } else { + throw err + } + } + res.set('Content-Type', 'application/octet-stream') + await pipeline(stream, res) +} + +async function getSnapshotAtVersion(projectId, version) { + const chunk = await chunkStore.loadAtVersion(projectId, version) + const snapshot = chunk.getSnapshot() + const changes = _.dropRight( + chunk.getChanges(), + chunk.getEndVersion() - version + ) + snapshot.applyAll(changes) + return snapshot +} + +module.exports = { + initializeProject: expressify(initializeProject), + getLatestContent: expressify(getLatestContent), + getLatestHashedContent: expressify(getLatestHashedContent), + getLatestPersistedHistory: expressify(getLatestHistory), + getLatestHistory: expressify(getLatestHistory), + getHistory: expressify(getHistory), + getHistoryBefore: expressify(getHistoryBefore), + getZip: expressify(getZip), + createZip: expressify(createZip), + deleteProject: expressify(deleteProject), + createProjectBlob: expressify(createProjectBlob), + getProjectBlob: expressify(getProjectBlob), +} diff --git a/services/history-v1/api/controllers/render.js b/services/history-v1/api/controllers/render.js new file mode 100644 index 0000000000..d7d3191507 --- /dev/null +++ b/services/history-v1/api/controllers/render.js @@ -0,0 +1,17 @@ +'use strict' + +const HTTPStatus = require('http-status') + +function makeErrorRenderer(status) { + return (res, message) => { + res.status(status).json({ message: message || HTTPStatus[status] }) + } +} + +module.exports = { + badRequest: makeErrorRenderer(HTTPStatus.BAD_REQUEST), + notFound: makeErrorRenderer(HTTPStatus.NOT_FOUND), + unprocessableEntity: makeErrorRenderer(HTTPStatus.UNPROCESSABLE_ENTITY), + conflict: makeErrorRenderer(HTTPStatus.CONFLICT), + requestEntityTooLarge: makeErrorRenderer(HTTPStatus.REQUEST_ENTITY_TOO_LARGE), +} diff --git a/services/history-v1/api/controllers/stream_size_limit.js b/services/history-v1/api/controllers/stream_size_limit.js new file mode 100644 index 0000000000..fbb2ab8030 --- /dev/null +++ b/services/history-v1/api/controllers/stream_size_limit.js @@ -0,0 +1,26 @@ +const stream = require('stream') + +/** + * Transform stream that stops passing bytes through after some threshold has + * been reached. + */ +class StreamSizeLimit extends stream.Transform { + constructor(maxSize) { + super() + this.maxSize = maxSize + this.accumulatedSize = 0 + this.sizeLimitExceeded = false + } + + _transform(chunk, encoding, cb) { + this.accumulatedSize += chunk.length + if (this.accumulatedSize > this.maxSize) { + this.sizeLimitExceeded = true + } else { + this.push(chunk) + } + cb() + } +} + +module.exports = StreamSizeLimit diff --git a/services/history-v1/api/controllers/with_tmp_dir.js b/services/history-v1/api/controllers/with_tmp_dir.js new file mode 100644 index 0000000000..ab2279ce17 --- /dev/null +++ b/services/history-v1/api/controllers/with_tmp_dir.js @@ -0,0 +1,27 @@ +const fs = require('fs') +const fsExtra = require('fs-extra') +const logger = require('@overleaf/logger') +const os = require('os') +const path = require('path') + +/** + * Create a temporary directory before executing a function and cleaning up + * after. + * + * @param {string} prefix - prefix for the temporary directory name + * @param {Function} fn - async function to call + */ +async function withTmpDir(prefix, fn) { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)) + try { + await fn(tmpDir) + } finally { + fsExtra.remove(tmpDir).catch(err => { + if (err.code !== 'ENOENT') { + logger.error({ err }, 'failed to delete temporary file') + } + }) + } +} + +module.exports = withTmpDir diff --git a/services/history-v1/api/swagger/index.js b/services/history-v1/api/swagger/index.js new file mode 100644 index 0000000000..edfb68f4e4 --- /dev/null +++ b/services/history-v1/api/swagger/index.js @@ -0,0 +1,256 @@ +'use strict' + +const _ = require('lodash') +const paths = _.reduce( + [require('./projects').paths, require('./project_import').paths], + _.extend +) + +const securityDefinitions = require('./security_definitions') +module.exports = { + swagger: '2.0', + info: { + title: 'Overleaf Editor API', + description: 'API for the Overleaf editor.', + version: '1.0', + }, + produces: ['application/json'], + basePath: '/api', + paths, + securityDefinitions, + security: [ + { + jwt: [], + }, + ], + definitions: { + Project: { + properties: { + projectId: { + type: 'string', + }, + }, + required: ['projectId'], + }, + File: { + properties: { + hash: { + type: 'string', + }, + byteLength: { + type: 'integer', + }, + stringLength: { + type: 'integer', + }, + }, + }, + Label: { + properties: { + authorId: { + type: 'integer', + }, + text: { + type: 'string', + }, + timestamp: { + type: 'string', + }, + version: { + type: 'integer', + }, + }, + }, + Chunk: { + properties: { + history: { + $ref: '#/definitions/History', + }, + startVersion: { + type: 'number', + }, + }, + }, + ChunkResponse: { + properties: { + chunk: { + $ref: '#/definitions/Chunk', + }, + authors: { + type: 'array', + items: { + $ref: '#/definitions/Author', + }, + }, + }, + }, + History: { + properties: { + snapshot: { + $ref: '#/definitions/Snapshot', + }, + changes: { + type: 'array', + items: { + $ref: '#/definitions/Change', + }, + }, + }, + }, + Snapshot: { + properties: { + files: { + type: 'object', + additionalProperties: { + $ref: '#/definitions/File', + }, + }, + }, + required: ['files'], + }, + Change: { + properties: { + timestamp: { + type: 'string', + }, + operations: { + type: 'array', + items: { + $ref: '#/definitions/Operation', + }, + }, + authors: { + type: 'array', + items: { + type: ['integer', 'null'], + }, + }, + v2Authors: { + type: 'array', + items: { + type: ['string', 'null'], + }, + }, + projectVersion: { + type: 'string', + }, + v2DocVersions: { + type: 'object', + additionalProperties: { + $ref: '#/definitions/V2DocVersions', + }, + }, + }, + required: ['timestamp', 'operations'], + }, + V2DocVersions: { + properties: { + pathname: { + type: 'string', + }, + v: { + type: 'integer', + }, + }, + }, + ChangeRequest: { + properties: { + baseVersion: { + type: 'integer', + }, + untransformable: { + type: 'boolean', + }, + operations: { + type: 'array', + items: { + $ref: '#/definitions/Operation', + }, + }, + authors: { + type: 'array', + items: { + type: ['integer', 'null'], + }, + }, + }, + required: ['baseVersion', 'operations'], + }, + ChangeNote: { + properties: { + baseVersion: { + type: 'integer', + }, + change: { + $ref: '#/definitions/Change', + }, + }, + required: ['baseVersion'], + }, + Operation: { + properties: { + pathname: { + type: 'string', + }, + newPathname: { + type: 'string', + }, + blob: { + $ref: '#/definitions/Blob', + }, + textOperation: { + type: 'array', + items: {}, + }, + file: { + $ref: '#/definitions/File', + }, + }, + }, + Error: { + properties: { + message: { + type: 'string', + }, + }, + required: ['message'], + }, + Blob: { + properties: { + hash: { + type: 'string', + }, + }, + required: ['hash'], + }, + Author: { + properties: { + id: { + type: 'integer', + }, + email: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + required: ['id', 'email', 'name'], + }, + SyncState: { + properties: { + synced: { + type: 'boolean', + }, + }, + }, + ZipInfo: { + properties: { + zipUrl: { + type: 'string', + }, + }, + required: ['zipUrl'], + }, + }, +} diff --git a/services/history-v1/api/swagger/project_import.js b/services/history-v1/api/swagger/project_import.js new file mode 100644 index 0000000000..60eb47fce4 --- /dev/null +++ b/services/history-v1/api/swagger/project_import.js @@ -0,0 +1,108 @@ +'use strict' + +const importSnapshot = { + 'x-swagger-router-controller': 'project_import', + operationId: 'importSnapshot', + tags: ['ProjectImport'], + description: 'Import a snapshot from the current rails app.', + consumes: ['application/json'], + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'snapshot', + in: 'body', + description: 'Snapshot to import.', + required: true, + schema: { + $ref: '#/definitions/Snapshot', + }, + }, + ], + responses: { + 200: { + description: 'Imported', + }, + 409: { + description: 'Conflict: project already initialized', + }, + 404: { + description: 'No such project exists', + }, + }, + security: [ + { + basic: [], + }, + ], +} + +const importChanges = { + 'x-swagger-router-controller': 'project_import', + operationId: 'importChanges', + tags: ['ProjectImport'], + description: 'Import changes for a project from the current rails app.', + consumes: ['application/json'], + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'end_version', + description: 'end_version of latest persisted chunk', + in: 'query', + required: true, + type: 'number', + }, + { + name: 'return_snapshot', + description: + 'optionally, return a snapshot with the latest hashed content', + in: 'query', + required: false, + type: 'string', + enum: ['hashed', 'none'], + }, + { + name: 'changes', + in: 'body', + description: 'changes to be imported', + required: true, + schema: { + type: 'array', + items: { + $ref: '#/definitions/Change', + }, + }, + }, + ], + responses: { + 201: { + description: 'Created', + schema: { + $ref: '#/definitions/Snapshot', + }, + }, + }, + security: [ + { + basic: [], + }, + ], +} + +exports.paths = { + '/projects/{project_id}/import': { post: importSnapshot }, + '/projects/{project_id}/legacy_import': { post: importSnapshot }, + '/projects/{project_id}/changes': { post: importChanges }, + '/projects/{project_id}/legacy_changes': { post: importChanges }, +} diff --git a/services/history-v1/api/swagger/projects.js b/services/history-v1/api/swagger/projects.js new file mode 100644 index 0000000000..964b32cefe --- /dev/null +++ b/services/history-v1/api/swagger/projects.js @@ -0,0 +1,429 @@ +'use strict' + +const Blob = require('overleaf-editor-core').Blob + +exports.paths = { + '/projects': { + post: { + 'x-swagger-router-controller': 'projects', + operationId: 'initializeProject', + tags: ['Project'], + description: 'Initialize project.', + parameters: [ + { + name: 'body', + in: 'body', + schema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + }, + }, + }, + ], + responses: { + 200: { + description: 'Initialized', + schema: { + $ref: '#/definitions/Project', + }, + }, + }, + security: [ + { + basic: [], + }, + ], + }, + }, + '/projects/{project_id}': { + delete: { + 'x-swagger-router-controller': 'projects', + operationId: 'deleteProject', + tags: ['Project'], + description: "Delete a project's history", + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + ], + responses: { + 204: { + description: 'Success', + }, + }, + security: [ + { + basic: [], + }, + ], + }, + }, + '/projects/{project_id}/blobs/{hash}': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getProjectBlob', + tags: ['Project'], + description: 'Fetch blob content by its project id and hash.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'hash', + in: 'path', + description: 'Hexadecimal SHA-1 hash', + required: true, + type: 'string', + pattern: Blob.HEX_HASH_RX_STRING, + }, + ], + produces: ['application/octet-stream'], + responses: { + 200: { + description: 'Success', + schema: { + type: 'file', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + security: [{ jwt: [] }, { token: [] }], + }, + put: { + 'x-swagger-router-controller': 'projects', + operationId: 'createProjectBlob', + tags: ['Project'], + description: + 'Create blob to be used in a file addition operation when importing a' + + ' snapshot or changes', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'hash', + in: 'path', + description: 'Hexadecimal SHA-1 hash', + required: true, + type: 'string', + pattern: Blob.HEX_HASH_RX_STRING, + }, + ], + responses: { + 201: { + description: 'Created', + }, + }, + }, + }, + '/projects/{project_id}/latest/content': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getLatestContent', + tags: ['Project'], + description: + 'Get full content of the latest version. Text file ' + + 'content is included, but binary files are just linked by hash.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/Snapshot', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + }, + }, + '/projects/{project_id}/latest/hashed_content': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getLatestHashedContent', + tags: ['Project'], + description: + 'Get a snapshot of a project at the latest version ' + + 'with the hashes for the contents each file', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/Snapshot', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + security: [ + { + basic: [], + }, + ], + }, + }, + '/projects/{project_id}/latest/history': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getLatestHistory', + tags: ['Project'], + description: + 'Get the latest sequence of changes.' + + ' TODO probably want a configurable depth.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/ChunkResponse', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + }, + }, + '/projects/{project_id}/latest/persistedHistory': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getLatestPersistedHistory', + tags: ['Project'], + description: 'Get the latest sequence of changes.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/ChunkResponse', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + }, + }, + + '/projects/{project_id}/versions/{version}/history': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getHistory', + tags: ['Project'], + description: + 'Get the sequence of changes that includes the given version.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'version', + in: 'path', + description: 'numeric version', + required: true, + type: 'number', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/ChunkResponse', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + }, + }, + '/projects/{project_id}/timestamp/{timestamp}/history': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getHistoryBefore', + tags: ['Project'], + description: + 'Get the sequence of changes. ' + ' before the given timestamp', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'timestamp', + in: 'path', + description: 'timestamp', + required: true, + type: 'string', + format: 'date-time', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + $ref: '#/definitions/ChunkResponse', + }, + }, + 404: { + description: 'Not Found', + schema: { + $ref: '#/definitions/Error', + }, + }, + }, + }, + }, + '/projects/{project_id}/version/{version}/zip': { + get: { + 'x-swagger-router-controller': 'projects', + operationId: 'getZip', + tags: ['Project'], + description: 'Download zip with project content', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'version', + in: 'path', + description: 'numeric version', + required: true, + type: 'number', + }, + ], + produces: ['application/octet-stream'], + responses: { + 200: { + description: 'success', + }, + 404: { + description: 'not found', + }, + }, + security: [ + { + token: [], + }, + ], + }, + post: { + 'x-swagger-router-controller': 'projects', + operationId: 'createZip', + tags: ['Project'], + description: + 'Create a zip file with project content. Returns a link to be polled.', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'version', + in: 'path', + description: 'numeric version', + required: true, + type: 'number', + }, + ], + responses: { + 200: { + description: 'success', + schema: { + $ref: '#/definitions/ZipInfo', + }, + }, + 404: { + description: 'not found', + }, + }, + security: [ + { + basic: [], + }, + ], + }, + }, +} diff --git a/services/history-v1/api/swagger/security_definitions.js b/services/history-v1/api/swagger/security_definitions.js new file mode 100644 index 0000000000..5b80a97cba --- /dev/null +++ b/services/history-v1/api/swagger/security_definitions.js @@ -0,0 +1,17 @@ +'use strict' + +module.exports = { + jwt: { + type: 'apiKey', + in: 'header', + name: 'authorization', + }, + basic: { + type: 'basic', + }, + token: { + type: 'apiKey', + in: 'query', + name: 'token', + }, +} diff --git a/services/history-v1/app.js b/services/history-v1/app.js new file mode 100644 index 0000000000..1d7f9b08cd --- /dev/null +++ b/services/history-v1/app.js @@ -0,0 +1,169 @@ +'use strict' + +/* eslint-disable no-console */ + +// Initialize metrics as early as possible because this is where the Google +// profiling agents are also started. +const Metrics = require('@overleaf/metrics') +Metrics.initialize('history-v1') + +const BPromise = require('bluebird') +const express = require('express') +const cors = require('cors') +const helmet = require('helmet') +const HTTPStatus = require('http-status') +const logger = require('@overleaf/logger') +const cookieParser = require('cookie-parser') +const bodyParser = require('body-parser') +const swaggerTools = require('swagger-tools') +const swaggerDoc = require('./api/swagger') +const security = require('./api/app/security') +const healthChecks = require('./api/controllers/health_checks') +const { mongodb, loadGlobalBlobs } = require('./storage') + +const app = express() +module.exports = app + +logger.initialize('history-v1') +Metrics.injectMetricsRoute(app) +app.use(Metrics.http.monitor(logger)) + +// We may have fairly large JSON bodies when receiving large Changes. Clients +// may have to handle 413 status codes and try creating files instead of sending +// text content in changes. +app.use(bodyParser.json({ limit: '4MB' })) +app.use( + bodyParser.urlencoded({ + extended: false, + }) +) +app.use(cookieParser()) +app.use(cors()) + +security.setupSSL(app) +security.setupBasicHttpAuthForSwaggerDocs(app) + +app.use(function (req, res, next) { + // use a 5 minute timeout on all responses + res.setTimeout(5 * 60 * 1000) + next() +}) + +app.get('/', function (req, res) { + res.send('') +}) + +app.get('/status', healthChecks.status) +app.get('/health_check', healthChecks.healthCheck) + +function setupSwagger() { + return new BPromise(function (resolve) { + swaggerTools.initializeMiddleware(swaggerDoc, function (middleware) { + app.use(middleware.swaggerMetadata()) + app.use(middleware.swaggerSecurity(security.getSwaggerHandlers())) + app.use(middleware.swaggerValidator()) + app.use( + middleware.swaggerRouter({ + controllers: './api/controllers', + useStubs: app.get('env') === 'development', + }) + ) + app.use(middleware.swaggerUi()) + resolve() + }) + }) +} + +function setupErrorHandling() { + app.use(function (req, res, next) { + const err = new Error('Not Found') + err.status = HTTPStatus.NOT_FOUND + return next(err) + }) + + // Handle Swagger errors. + app.use(function (err, req, res, next) { + if (res.headersSent) { + return next(err) + } + + if (err.code === 'SCHEMA_VALIDATION_FAILED') { + logger.error(err) + return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json(err.results) + } + if (err.code === 'INVALID_TYPE' || err.code === 'PATTERN') { + logger.error(err) + return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ + message: 'invalid type: ' + err.paramName, + }) + } + if (err.code === 'ENUM_MISMATCH') { + return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ + message: 'invalid enum value: ' + err.paramName, + }) + } + if (err.code === 'REQUIRED') { + return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ + message: err.message, + }) + } + next(err) + }) + + app.use(function (err, req, res, next) { + logger.error(err) + + if (res.headersSent) { + return next(err) + } + + // Handle errors that specify a statusCode. Some come from our code. Some + // bubble up from AWS SDK, but they sometimes have the statusCode set to + // 200, notably some InternalErrors and TimeoutErrors, so we have to guard + // against that. We also check `status`, but `statusCode` is preferred. + const statusCode = err.statusCode || err.status + if (statusCode && statusCode >= 400 && statusCode < 600) { + res.status(statusCode) + } else { + res.status(HTTPStatus.INTERNAL_SERVER_ERROR) + } + + const sendErrorToClient = app.get('env') === 'development' + res.json({ + message: err.message, + error: sendErrorToClient ? err : {}, + }) + }) +} + +app.setup = async function appSetup() { + await mongodb.client.connect() + logger.info('Connected to MongoDB') + await loadGlobalBlobs() + logger.info('Global blobs loaded') + app.use(helmet()) + await setupSwagger() + setupErrorHandling() +} + +async function startApp() { + await app.setup() + + const port = parseInt(process.env.PORT, 10) || 3100 + app.listen(port, err => { + if (err) { + console.error(err) + process.exit(1) + } + Metrics.event_loop.monitor(logger) + Metrics.memory.monitor(logger) + }) +} + +// Run this if we're called directly +if (!module.parent) { + startApp().catch(err => { + console.error(err) + process.exit(1) + }) +} diff --git a/services/history-v1/benchmarks/blob_store.js b/services/history-v1/benchmarks/blob_store.js new file mode 100644 index 0000000000..2efb90ffb2 --- /dev/null +++ b/services/history-v1/benchmarks/blob_store.js @@ -0,0 +1,82 @@ +const crypto = require('crypto') +const benny = require('benny') +const { Blob } = require('overleaf-editor-core') +const mongoBackend = require('../storage/lib/blob_store/mongo') +const postgresBackend = require('../storage/lib/blob_store/postgres') +const cleanup = require('../test/acceptance/js/storage/support/cleanup') + +const MONGO_PROJECT_ID = '637386deb4ce3c62acd3848e' +const POSTGRES_PROJECT_ID = '123' + +async function run() { + for (const blobCount of [1, 10, 100, 1000, 10000, 100000, 500000]) { + await cleanup.everything() + const blobs = createBlobs(blobCount) + await insertBlobs(blobs) + const randomHashes = getRandomHashes(blobs, 100) + await benny.suite( + `Read a blob in a project with ${blobCount} blobs`, + benny.add('Mongo backend', async () => { + await mongoBackend.findBlob(MONGO_PROJECT_ID, randomHashes[0]) + }), + benny.add('Postgres backend', async () => { + await postgresBackend.findBlob(POSTGRES_PROJECT_ID, randomHashes[0]) + }), + benny.cycle(), + benny.complete() + ) + await benny.suite( + `Read 100 blobs in a project with ${blobCount} blobs`, + benny.add('Mongo backend', async () => { + await mongoBackend.findBlobs(MONGO_PROJECT_ID, randomHashes) + }), + benny.add('Postgres backend', async () => { + await postgresBackend.findBlobs(POSTGRES_PROJECT_ID, randomHashes) + }), + benny.cycle(), + benny.complete() + ) + await benny.suite( + `Insert a blob in a project with ${blobCount} blobs`, + benny.add('Mongo backend', async () => { + const [newBlob] = createBlobs(1) + await mongoBackend.insertBlob(MONGO_PROJECT_ID, newBlob) + }), + benny.add('Postgres backend', async () => { + const [newBlob] = createBlobs(1) + await postgresBackend.insertBlob(POSTGRES_PROJECT_ID, newBlob) + }), + benny.cycle(), + benny.complete() + ) + } +} + +function createBlobs(blobCount) { + const blobs = [] + for (let i = 0; i < blobCount; i++) { + const hash = crypto.randomBytes(20).toString('hex') + blobs.push(new Blob(hash, 42, 42)) + } + return blobs +} + +async function insertBlobs(blobs) { + for (const blob of blobs) { + await Promise.all([ + mongoBackend.insertBlob(MONGO_PROJECT_ID, blob), + postgresBackend.insertBlob(POSTGRES_PROJECT_ID, blob), + ]) + } +} + +function getRandomHashes(blobs, count) { + const hashes = [] + for (let i = 0; i < count; i++) { + const index = Math.floor(Math.random() * blobs.length) + hashes.push(blobs[index].getHash()) + } + return hashes +} + +module.exports = run diff --git a/services/history-v1/benchmarks/index.js b/services/history-v1/benchmarks/index.js new file mode 100644 index 0000000000..5cc5bafeb4 --- /dev/null +++ b/services/history-v1/benchmarks/index.js @@ -0,0 +1,17 @@ +const testSetup = require('../test/setup') +const blobStoreSuite = require('./blob_store') + +async function main() { + await testSetup.setupPostgresDatabase() + await testSetup.createGcsBuckets() + await blobStoreSuite() +} + +main() + .then(() => { + process.exit(0) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt new file mode 100644 index 0000000000..a0f67d781a --- /dev/null +++ b/services/history-v1/buildscript.txt @@ -0,0 +1,8 @@ +history-v1 +--dependencies=postgres,gcs,mongo +--docker-repos=gcr.io/overleaf-ops +--env-add= +--env-pass-through= +--node-version=16.17.1 +--public-repo=False +--script-version=4.1.0 diff --git a/services/history-v1/cloud-formation.json b/services/history-v1/cloud-formation.json new file mode 100644 index 0000000000..9088756d24 --- /dev/null +++ b/services/history-v1/cloud-formation.json @@ -0,0 +1,572 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Metadata": { + "AWS::CloudFormation::Designer": { + "ee78c12d-0d1e-4ca0-8fa9-ba02f49d071c": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 30, + "y": 60 + }, + "z": 0, + "embeds": [] + }, + "a52902b8-f027-45a8-9151-3e56ced5fb42": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 30, + "y": 140 + }, + "z": 0, + "embeds": [] + }, + "674a64fc-3703-4222-91b9-4878490489e2": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 250, + "y": 100 + }, + "z": 0, + "embeds": [], + "isassociatedwith": [ + "5c314e8e-535b-4b09-8bb7-c089794a3829" + ] + }, + "5c314e8e-535b-4b09-8bb7-c089794a3829": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 250, + "y": 210 + }, + "z": 0, + "embeds": [] + }, + "3da9a376-afc1-4b37-add1-9cf0df20b0a0": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 360, + "y": 100 + }, + "z": 0, + "embeds": [] + }, + "7fd11cc7-5574-44f3-99df-877b6f0f2a74": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 130, + "y": 60 + }, + "z": 0, + "embeds": [], + "isassociatedwith": [ + "ee78c12d-0d1e-4ca0-8fa9-ba02f49d071c" + ] + }, + "1d8a8e19-2661-44d4-99c0-4a2c88c8557d": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 130, + "y": 140 + }, + "z": 0, + "embeds": [], + "isassociatedwith": [ + "a52902b8-f027-45a8-9151-3e56ced5fb42" + ] + }, + "e29c9a81-85ad-4511-ab1e-018fe50f1573": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 30, + "y": 220 + }, + "z": 0, + "embeds": [] + }, + "1388662c-85e1-4f6e-9b80-0f1888a6e07d": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 130, + "y": 220 + }, + "z": 0, + "embeds": [], + "isassociatedwith": [ + "e29c9a81-85ad-4511-ab1e-018fe50f1573" + ] + }, + "236600ec-46ca-4770-8d7c-61532a6d8c27": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 30, + "y": 300 + }, + "z": 0, + "embeds": [] + }, + "454a6298-2f35-48d7-8cd5-3152d78a585b": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 130, + "y": 300 + }, + "z": 0, + "embeds": [] + } + } + }, + "Resources": { + "Blobs": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "-", + [ + { + "Ref": "OverleafEditorBucketPrefix" + }, + "blobs" + ] + ] + }, + "VersioningConfiguration": { + "Status": "Enabled" + }, + "LifecycleConfiguration": { + "Rules": [ + { + "NoncurrentVersionExpirationInDays": 90, + "Status": "Enabled" + } + ] + }, + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "ee78c12d-0d1e-4ca0-8fa9-ba02f49d071c" + } + } + }, + "Chunks": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "-", + [ + { + "Ref": "OverleafEditorBucketPrefix" + }, + "chunks" + ] + ] + }, + "VersioningConfiguration": { + "Status": "Enabled" + }, + "LifecycleConfiguration": { + "Rules": [ + { + "NoncurrentVersionExpirationInDays": 80, + "Status": "Enabled" + } + ] + }, + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "a52902b8-f027-45a8-9151-3e56ced5fb42" + } + } + }, + "APIUser": { + "Type": "AWS::IAM::User", + "Properties": { + "Groups": [ + { + "Ref": "APIGroup" + } + ] + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "674a64fc-3703-4222-91b9-4878490489e2" + } + } + }, + "APIGroup": { + "Type": "AWS::IAM::Group", + "Properties": {}, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "5c314e8e-535b-4b09-8bb7-c089794a3829" + } + } + }, + "APIUserAccessKey": { + "Type": "AWS::IAM::AccessKey", + "Properties": { + "UserName": { + "Ref": "APIUser" + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "3da9a376-afc1-4b37-add1-9cf0df20b0a0" + } + } + }, + "BlobsPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Blobs" + }, + "PolicyDocument": { + "Id": "BlobsPolicy", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "BlobsPolicyAPIUser", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "Blobs" + }, + "/*" + ] + ] + }, + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "APIUser", + "Arn" + ] + } + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "7fd11cc7-5574-44f3-99df-877b6f0f2a74" + } + } + }, + "ChunksPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Chunks" + }, + "PolicyDocument": { + "Id": "ChunksPolicy", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ChunksPolicyAPIUser", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "Chunks" + }, + "/*" + ] + ] + }, + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "APIUser", + "Arn" + ] + } + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "1d8a8e19-2661-44d4-99c0-4a2c88c8557d" + } + } + }, + "Zips": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "-", + [ + { + "Ref": "OverleafEditorBucketPrefix" + }, + "zips" + ] + ] + }, + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + }, + "LifecycleConfiguration": { + "Rules": [ + { + "ExpirationInDays": 1, + "Status": "Enabled" + } + ] + }, + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "e29c9a81-85ad-4511-ab1e-018fe50f1573" + } + } + }, + "ZipsPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Zips" + }, + "PolicyDocument": { + "Id": "ZipsPolicy", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ZipsPolicyAPIUser", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "Zips" + }, + "/*" + ] + ] + }, + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "APIUser", + "Arn" + ] + } + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "1388662c-85e1-4f6e-9b80-0f1888a6e07d" + } + } + }, + "Analytics": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "-", + [ + { + "Ref": "OverleafEditorBucketPrefix" + }, + "analytics" + ] + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "236600ec-46ca-4770-8d7c-61532a6d8c27" + } + } + }, + "AnalyticsPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Analytics" + }, + "PolicyDocument": { + "Id": "AnalyticsPolicy", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AnalyticsPolicyAPIUser", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "Analytics" + }, + "/*" + ] + ] + }, + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "APIUser", + "Arn" + ] + } + } + }, + { + "Sid": "AnalyticsPolicyAPIUserBucketPerms", + "Action": "s3:ListBucket", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3:::", + { + "Ref": "Analytics" + } + ] + ] + }, + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "APIUser", + "Arn" + ] + } + } + } + ] + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "454a6298-2f35-48d7-8cd5-3152d78a585b" + } + } + } + }, + "Parameters": { + "OverleafEditorBucketPrefix": { + "Description": "Prefix for the S3 bucket names (e.g. production-overleaf-editor or staging-overleaf-editor)", + "Type": "String" + } + }, + "Outputs": { + "APIUserAccessKey": { + "Value": { + "Ref": "APIUserAccessKey" + } + }, + "APIUserSecretKey": { + "Value": { + "Fn::GetAtt": [ + "APIUserAccessKey", + "SecretAccessKey" + ] + } + } + } +} diff --git a/services/history-v1/config/custom-environment-variables.json b/services/history-v1/config/custom-environment-variables.json new file mode 100644 index 0000000000..e4725ab0cb --- /dev/null +++ b/services/history-v1/config/custom-environment-variables.json @@ -0,0 +1,60 @@ +{ + "databaseUrl": "HISTORY_CONNECTION_STRING", + "herokuDatabaseUrl": "DATABASE_URL", + "databasePoolMin": "DATABASE_POOL_MIN", + "databasePoolMax": "DATABASE_POOL_MAX", + "persistor": { + "backend": "PERSISTOR_BACKEND", + "s3": { + "key": "AWS_ACCESS_KEY_ID", + "secret": "AWS_SECRET_ACCESS_KEY", + "maxRetries": "S3_MAX_RETRIES", + "httpOptions": { + "timeout": "S3_TIMEOUT" + } + }, + "gcs": { + "deletedBucketSuffix": "GCS_DELETED_BUCKET_SUFFIX", + "unlockBeforeDelete": "GCS_UNLOCK_BEFORE_DELETE", + "endpoint": { + "apiEndpoint": "GCS_API_ENDPOINT", + "apiScheme": "GCS_API_SCHEME", + "projectId": "GCS_PROJECT_ID" + } + }, + "fallback": { + "backend": "PERSISTOR_FALLBACK_BACKEND", + "buckets": "PERSISTOR_BUCKET_MAPPING" + } + }, + "blobStore": { + "globalBucket": "OVERLEAF_EDITOR_BLOBS_BUCKET", + "projectBucket": "OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET" + }, + "chunkStore": { + "historyStoreConcurrency": "HISTORY_STORE_CONCURRENCY", + "bucket": "OVERLEAF_EDITOR_CHUNKS_BUCKET" + }, + "zipStore": { + "bucket": "OVERLEAF_EDITOR_ZIPS_BUCKET", + "zipTimeoutMs": "ZIP_STORE_ZIP_TIMEOUT_MS" + }, + "analytics": { + "bucket": "OVERLEAF_EDITOR_ANALYTICS_BUCKET" + }, + "mongo": { + "uri": "MONGO_CONNECTION_STRING" + }, + "basicHttpAuth": { + "password": "STAGING_PASSWORD", + "oldPassword": "BASIC_HTTP_AUTH_OLD_PASSWORD" + }, + "jwtAuth": { + "key": "OT_JWT_AUTH_KEY", + "oldKey": "OT_JWT_AUTH_OLD_KEY", + "algorithm": "OT_JWT_AUTH_ALG" + }, + "clusterWorkers": "CLUSTER_WORKERS", + "maxFileUploadSize": "MAX_FILE_UPLOAD_SIZE", + "httpsOnly": "HTTPS_ONLY" +} diff --git a/services/history-v1/config/default.json b/services/history-v1/config/default.json new file mode 100644 index 0000000000..ff15214f90 --- /dev/null +++ b/services/history-v1/config/default.json @@ -0,0 +1,29 @@ +{ + "persistor": { + "backend": "s3", + "s3": { + "signedUrlExpiryInMs": "1800000", + "maxRetries": "1", + "httpOptions": { + "timeout": "8000" + } + }, + "gcs": { + "signedUrlExpiryInMs": "1800000", + "deleteConcurrency": "50" + } + }, + "chunkStore": { + "historyStoreConcurrency": "4" + }, + "zipStore": { + "zipTimeoutMs": "360000" + }, + "maxDeleteKeys": "1000", + "useDeleteObjects": "true", + "clusterWorkers": "1", + "maxFileUploadSize": "52428800", + "databasePoolMin": "2", + "databasePoolMax": "10", + "httpsOnly": "false" +} diff --git a/services/history-v1/config/development.json b/services/history-v1/config/development.json new file mode 100644 index 0000000000..cdf3fca1a7 --- /dev/null +++ b/services/history-v1/config/development.json @@ -0,0 +1,41 @@ +{ + "databaseUrl": "postgres://postgres:postgres@postgres/write_latex_dev", + "persistor": { + "s3": { + "endpoint": "http://s3:8080", + "pathStyle": "true" + }, + "gcs": { + "unsignedUrls": "true", + "endpoint": { + "apiEndpoint": "fake-gcs:9090", + "apiScheme": "http", + "projectId": "fake" + } + } + }, + "blobStore": { + "globalBucket": "overleaf-development-blobs", + "projectBucket": "overleaf-development-project-blobs" + }, + "chunkStore": { + "bucket": "overleaf-development-chunks" + }, + "zipStore": { + "bucket": "overleaf-development-zips" + }, + "analytics": { + "bucket": "overleaf-development-analytics" + }, + "useDeleteObjects": "false", + "mongo": { + "uri": "mongodb://mongo:27017/sharelatex" + }, + "basicHttpAuth": { + "password": "password" + }, + "jwtAuth": { + "key": "secureKey", + "algorithm": "HS256" + } +} diff --git a/services/history-v1/config/production.json b/services/history-v1/config/production.json new file mode 100644 index 0000000000..ffcd4415b0 --- /dev/null +++ b/services/history-v1/config/production.json @@ -0,0 +1 @@ +{ } diff --git a/services/history-v1/config/test.json b/services/history-v1/config/test.json new file mode 100644 index 0000000000..b7f203a35b --- /dev/null +++ b/services/history-v1/config/test.json @@ -0,0 +1,40 @@ +{ + "databaseUrl": "postgres://sharelatex:sharelatex@postgres/sharelatex", + "persistor": { + "backend": "gcs", + "gcs": { + "unsignedUrls": "true", + "endpoint": { + "apiEndpoint": "gcs:9090", + "apiScheme": "http", + "projectId": "fake" + } + } + }, + "blobStore": { + "globalBucket": "overleaf-test-blobs", + "projectBucket": "overleaf-test-project-blobs" + }, + "chunkStore": { + "bucket": "overleaf-test-chunks" + }, + "zipStore": { + "bucket": "overleaf-test-zips" + }, + "analytics": { + "bucket": "overleaf-test-analytics" + }, + "maxDeleteKeys": "3", + "useDeleteObjects": "false", + "mongo": { + "uri": "mongodb://mongo:27017/sharelatex" + }, + "basicHttpAuth": { + "password": "test" + }, + "jwtAuth": { + "key": "testtest", + "algorithm": "HS256" + }, + "maxFileUploadSize": "524288" +} diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml new file mode 100644 index 0000000000..c6645aeef7 --- /dev/null +++ b/services/history-v1/docker-compose.ci.yml @@ -0,0 +1,74 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +version: "2.3" + +services: + test_unit: + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: node + command: npm run test:unit:_run + environment: + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + + + test_acceptance: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + ANALYTICS_QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + GCS_API_ENDPOINT: gcs:9090 + GCS_API_SCHEME: http + GCS_PROJECT_ID: fake + STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + depends_on: + mongo: + condition: service_healthy + postgres: + condition: service_healthy + gcs: + condition: service_healthy + user: node + command: npm run test:acceptance:_run + + + tar: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + volumes: + - ./:/tmp/build/ + command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . + user: root + mongo: + image: mongo:4.4.16 + healthcheck: + test: "mongo --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'" + interval: 1s + retries: 20 + postgres: + image: postgres:10 + environment: + POSTGRES_USER: sharelatex + POSTGRES_PASSWORD: sharelatex + healthcheck: + test: pg_isready --quiet + interval: 1s + retries: 20 + + gcs: + image: fsouza/fake-gcs-server:v1.21.2 + command: ["--port=9090", "--scheme=http"] + healthcheck: + test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b + interval: 1s + retries: 20 diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml new file mode 100644 index 0000000000..c953644e19 --- /dev/null +++ b/services/history-v1/docker-compose.yml @@ -0,0 +1,77 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +version: "2.3" + +services: + test_unit: + image: node:16.17.1 + volumes: + - .:/overleaf/services/history-v1 + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/history-v1 + environment: + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + command: npm run --silent test:unit + user: node + + test_acceptance: + image: node:16.17.1 + volumes: + - .:/overleaf/services/history-v1 + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/history-v1 + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + ANALYTICS_QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + GCS_API_ENDPOINT: gcs:9090 + GCS_API_SCHEME: http + GCS_PROJECT_ID: fake + STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ERROR + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + depends_on: + mongo: + condition: service_healthy + postgres: + condition: service_healthy + gcs: + condition: service_healthy + command: npm run --silent test:acceptance + + mongo: + image: mongo:4.4.16 + healthcheck: + test: "mongo --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'" + interval: 1s + retries: 20 + + postgres: + image: postgres:10 + environment: + POSTGRES_USER: sharelatex + POSTGRES_PASSWORD: sharelatex + healthcheck: + test: pg_isready --host=localhost --quiet + interval: 1s + retries: 20 + + gcs: + image: fsouza/fake-gcs-server:v1.21.2 + command: ["--port=9090", "--scheme=http"] + healthcheck: + test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b + interval: 1s + retries: 20 diff --git a/services/history-v1/knexfile.js b/services/history-v1/knexfile.js new file mode 100644 index 0000000000..6bdf8774c2 --- /dev/null +++ b/services/history-v1/knexfile.js @@ -0,0 +1,19 @@ +const config = require('config') + +const baseConfig = { + client: 'postgresql', + connection: config.herokuDatabaseUrl || config.databaseUrl, + pool: { + min: parseInt(config.databasePoolMin, 10), + max: parseInt(config.databasePoolMax, 10), + }, + migrations: { + tableName: 'knex_migrations', + }, +} + +module.exports = { + development: baseConfig, + production: baseConfig, + test: baseConfig, +} diff --git a/services/history-v1/migrations/20220228163642_initial.js b/services/history-v1/migrations/20220228163642_initial.js new file mode 100644 index 0000000000..560dd22846 --- /dev/null +++ b/services/history-v1/migrations/20220228163642_initial.js @@ -0,0 +1,80 @@ +/** + * This is the initial migration, meant to replicate the current state of the + * history database. If tables already exist, this migration is a noop. + */ + +exports.up = async function (knex) { + await knex.raw(` + CREATE TABLE IF NOT EXISTS chunks ( + id SERIAL, + doc_id integer NOT NULL, + end_version integer NOT NULL, + end_timestamp timestamp without time zone, + CONSTRAINT chunks_version_non_negative CHECK (end_version >= 0) + ) + `) + await knex.raw(` + CREATE UNIQUE INDEX IF NOT EXISTS index_chunks_on_doc_id_and_end_version + ON chunks (doc_id, end_version) + `) + + await knex.raw(` + CREATE TABLE IF NOT EXISTS old_chunks ( + chunk_id integer NOT NULL PRIMARY KEY, + doc_id integer NOT NULL, + end_version integer, + end_timestamp timestamp without time zone, + deleted_at timestamp without time zone + ) + `) + await knex.raw(` + CREATE INDEX IF NOT EXISTS index_old_chunks_on_doc_id_and_end_version + ON old_chunks (doc_id, end_version) + `) + + await knex.raw(` + CREATE TABLE IF NOT EXISTS pending_chunks ( + id SERIAL, + doc_id integer NOT NULL, + end_version integer NOT NULL, + end_timestamp timestamp without time zone, + CONSTRAINT chunks_version_non_negative CHECK (end_version >= 0) + ) + `) + await knex.raw(` + CREATE INDEX IF NOT EXISTS index_pending_chunks_on_doc_id_and_id + ON pending_chunks (doc_id, id) + `) + + await knex.raw(` + CREATE TABLE IF NOT EXISTS blobs ( + hash_bytes bytea NOT NULL PRIMARY KEY, + byte_length integer NOT NULL, + string_length integer, + global boolean, + CONSTRAINT blobs_byte_length_non_negative CHECK (byte_length >= 0), + CONSTRAINT blobs_string_length_non_negative + CHECK (string_length IS NULL OR string_length >= 0) + ) + `) + + await knex.raw(` + CREATE TABLE IF NOT EXISTS project_blobs ( + project_id integer NOT NULL, + hash_bytes bytea NOT NULL, + byte_length integer NOT NULL, + string_length integer, + PRIMARY KEY (project_id, hash_bytes), + CONSTRAINT project_blobs_byte_length_non_negative + CHECK (byte_length >= 0), + CONSTRAINT project_blobs_string_length_non_negative + CHECK (string_length IS NULL OR string_length >= 0) + ) + `) + + await knex.raw(`CREATE SEQUENCE IF NOT EXISTS docs_id_seq`) +} + +exports.down = async function (knex) { + // Don't do anything on the down migration +} diff --git a/services/history-v1/migrations/20221026201437_chunk_start_version.js b/services/history-v1/migrations/20221026201437_chunk_start_version.js new file mode 100644 index 0000000000..4aed9bcf65 --- /dev/null +++ b/services/history-v1/migrations/20221026201437_chunk_start_version.js @@ -0,0 +1,23 @@ +exports.up = async function (knex) { + await knex.raw(` + ALTER TABLE chunks ADD COLUMN start_version integer + `) + await knex.raw(` + ALTER TABLE pending_chunks ADD COLUMN start_version integer + `) + await knex.raw(` + ALTER TABLE old_chunks ADD COLUMN start_version integer + `) +} + +exports.down = async function (knex) { + await knex.raw(` + ALTER TABLE chunks DROP COLUMN start_version + `) + await knex.raw(` + ALTER TABLE pending_chunks DROP COLUMN start_version + `) + await knex.raw(` + ALTER TABLE old_chunks DROP COLUMN start_version + `) +} diff --git a/services/history-v1/migrations/20221027201324_unique_start_version.js b/services/history-v1/migrations/20221027201324_unique_start_version.js new file mode 100644 index 0000000000..2d7885edd2 --- /dev/null +++ b/services/history-v1/migrations/20221027201324_unique_start_version.js @@ -0,0 +1,41 @@ +exports.config = { + // CREATE INDEX CONCURRENTLY can't be run inside a transaction + // If this migration fails in the middle, indexes and constraints will have + // to be cleaned up manually. + transaction: false, +} + +exports.up = async function (knex) { + await knex.raw(` + ALTER TABLE chunks + ADD CONSTRAINT chunks_start_version_non_negative + CHECK (start_version IS NOT NULL AND start_version >= 0) + NOT VALID + `) + await knex.raw(` + ALTER TABLE chunks + VALIDATE CONSTRAINT chunks_start_version_non_negative + `) + await knex.raw(` + CREATE UNIQUE INDEX CONCURRENTLY index_chunks_on_doc_id_and_start_version + ON chunks (doc_id, start_version) + `) + await knex.raw(` + ALTER TABLE chunks + ADD UNIQUE USING INDEX index_chunks_on_doc_id_and_start_version + `) +} + +exports.down = async function (knex) { + await knex.raw(` + ALTER TABLE chunks + DROP CONSTRAINT IF EXISTS index_chunks_on_doc_id_and_start_version + `) + await knex.raw(` + DROP INDEX IF EXISTS index_chunks_on_doc_id_and_start_version + `) + await knex.raw(` + ALTER TABLE chunks + DROP CONSTRAINT IF EXISTS chunks_start_version_non_negative + `) +} diff --git a/services/history-v1/migrations/20221118213808_delete_global_blobs_table.js b/services/history-v1/migrations/20221118213808_delete_global_blobs_table.js new file mode 100644 index 0000000000..eb76dffb25 --- /dev/null +++ b/services/history-v1/migrations/20221118213808_delete_global_blobs_table.js @@ -0,0 +1,7 @@ +exports.up = async function (knex) { + await knex.raw(`DROP TABLE IF EXISTS blobs`) +} + +exports.down = function (knex) { + // Not reversible +} diff --git a/services/history-v1/nodemon.json b/services/history-v1/nodemon.json new file mode 100644 index 0000000000..4d98b88d89 --- /dev/null +++ b/services/history-v1/nodemon.json @@ -0,0 +1,20 @@ +{ + "ignore": [ + ".git", + "node_modules/" + ], + "verbose": true, + "legacyWatch": true, + "execMap": { + "js": "npm run start" + }, + "watch": [ + "api/", + "app/js/", + "app.js", + "config/", + "storage/", + "../../libraries/" + ], + "ext": "js" +} diff --git a/services/history-v1/package.json b/services/history-v1/package.json new file mode 100644 index 0000000000..1f16cf8d76 --- /dev/null +++ b/services/history-v1/package.json @@ -0,0 +1,68 @@ +{ + "name": "overleaf-editor", + "version": "1.0.0", + "description": "Overleaf Editor.", + "author": "", + "license": "Proprietary", + "private": true, + "dependencies": { + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "@overleaf/o-error": "*", + "@overleaf/object-persistor": "*", + "archiver": "^5.3.0", + "basic-auth": "^2.0.1", + "bluebird": "^3.7.2", + "body-parser": "^1.19.0", + "bunyan": "^1.8.12", + "check-types": "^11.1.2", + "command-line-args": "^3.0.3", + "config": "^1.19.0", + "cookie-parser": "~1.4.5", + "cors": "^2.8.5", + "express": "^4.17.1", + "fs-extra": "^9.0.1", + "generic-pool": "^2.1.1", + "helmet": "^3.22.0", + "http-status": "^1.4.2", + "jsonwebtoken": "^8.5.1", + "knex": "^2.4.0", + "lodash": "^4.17.19", + "mongodb": "^4.11.0", + "overleaf-editor-core": "*", + "pg": "^8.7.1", + "string-to-stream": "^1.0.1", + "swagger-tools": "^0.10.4", + "temp": "^0.8.3", + "throng": "^4.0.0", + "tsscmp": "^1.0.6", + "utf-8-validate": "^5.0.4" + }, + "devDependencies": { + "benny": "^3.7.1", + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "istanbul": "^0.4.5", + "mocha": "^8.4.0", + "node-fetch": "^2.6.7", + "sinon": "^9.0.2", + "swagger-client": "^3.10.0", + "yauzl": "^2.9.1" + }, + "scripts": { + "start": "node $NODE_APP_OPTIONS app.js", + "lint": "eslint --max-warnings 0 --format unix .", + "lint:fix": "eslint --fix .", + "format": "prettier --list-different $PWD/'**/*.js'", + "format:fix": "prettier --write $PWD/'**/*.js'", + "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", + "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", + "test:unit:_run": "mocha --recursive --reporter spec $@ test/unit/js", + "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js", + "nodemon": "nodemon --config nodemon.json", + "migrate": "knex migrate:latest", + "delete_old_chunks": "node storage/tasks/delete_old_chunks.js", + "fix_duplicate_versions": "node storage/tasks/fix_duplicate_versions.js", + "benchmarks": "node benchmarks/index.js" + } +} diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js new file mode 100644 index 0000000000..a0fd471829 --- /dev/null +++ b/services/history-v1/storage/index.js @@ -0,0 +1,17 @@ +exports.BatchBlobStore = require('./lib/batch_blob_store') +exports.blobHash = require('./lib/blob_hash') +exports.HashCheckBlobStore = require('./lib/hash_check_blob_store') +exports.chunkStore = require('./lib/chunk_store') +exports.historyStore = require('./lib/history_store') +exports.knex = require('./lib/knex') +exports.mongodb = require('./lib/mongodb') +exports.persistChanges = require('./lib/persist_changes') +exports.persistor = require('./lib/persistor') +exports.ProjectArchive = require('./lib/project_archive') +exports.streams = require('./lib/streams') +exports.temp = require('./lib/temp') +exports.zipStore = require('./lib/zip_store') + +const { BlobStore, loadGlobalBlobs } = require('./lib/blob_store') +exports.BlobStore = BlobStore +exports.loadGlobalBlobs = loadGlobalBlobs diff --git a/services/history-v1/storage/lib/assert.js b/services/history-v1/storage/lib/assert.js new file mode 100644 index 0000000000..6f79086209 --- /dev/null +++ b/services/history-v1/storage/lib/assert.js @@ -0,0 +1,52 @@ +'use strict' + +const check = require('check-types') +const { Blob } = require('overleaf-editor-core') + +const assert = check.assert + +const MONGO_ID_REGEXP = /^[0-9a-f]{24}$/ +const POSTGRES_ID_REGEXP = /^[1-9][0-9]{0,9}$/ +const PROJECT_ID_REGEXP = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ + +function transaction(transaction, message) { + assert.function(transaction, message) +} + +function blobHash(arg, message) { + assert.match(arg, Blob.HEX_HASH_RX, message) +} + +/** + * A chunk id is a string that contains either an integer (for projects stored in Postgres) or 24 + * hex digits (for projects stored in Mongo) + */ +function projectId(arg, message) { + assert.match(arg, PROJECT_ID_REGEXP, message) +} + +/** + * A chunk id is either a number (for projects stored in Postgres) or a 24 + * character string (for projects stored in Mongo) + */ +function chunkId(arg, message) { + const valid = check.integer(arg) || check.match(arg, MONGO_ID_REGEXP) + if (!valid) { + throw new TypeError(message) + } +} + +function mongoId(arg, message) { + assert.match(arg, MONGO_ID_REGEXP) +} + +module.exports = { + ...assert, + transaction, + blobHash, + projectId, + chunkId, + mongoId, + MONGO_ID_REGEXP, + POSTGRES_ID_REGEXP, +} diff --git a/services/history-v1/storage/lib/batch_blob_store.js b/services/history-v1/storage/lib/batch_blob_store.js new file mode 100644 index 0000000000..af90b2ec11 --- /dev/null +++ b/services/history-v1/storage/lib/batch_blob_store.js @@ -0,0 +1,40 @@ +'use strict' + +const BPromise = require('bluebird') + +/** + * @constructor + * @param {BlobStore} blobStore + * @classdesc + * Wrapper for BlobStore that pre-fetches blob metadata to avoid making one + * database call per blob lookup. + */ +function BatchBlobStore(blobStore) { + this.blobStore = blobStore + this.blobs = new Map() +} + +/** + * Pre-fetch metadata for the given blob hashes. + * + * @param {Array.} hashes + * @return {Promise} + */ +BatchBlobStore.prototype.preload = function batchBlobStorePreload(hashes) { + return BPromise.each(this.blobStore.getBlobs(hashes), blob => { + this.blobs.set(blob.getHash(), blob) + }) +} + +/** + * @see BlobStore#getBlob + */ +BatchBlobStore.prototype.getBlob = BPromise.method( + function batchBlobStoreGetBlob(hash) { + const blob = this.blobs.get(hash) + if (blob) return blob + return this.blobStore.getBlob(hash) + } +) + +module.exports = BatchBlobStore diff --git a/services/history-v1/storage/lib/blob_hash.js b/services/history-v1/storage/lib/blob_hash.js new file mode 100644 index 0000000000..334824c1e8 --- /dev/null +++ b/services/history-v1/storage/lib/blob_hash.js @@ -0,0 +1,78 @@ +/** @module */ +'use strict' + +const BPromise = require('bluebird') +const fs = BPromise.promisifyAll(require('fs')) +const crypto = require('crypto') +const assert = require('./assert') + +function getGitBlobHeader(byteLength) { + return 'blob ' + byteLength + '\x00' +} + +function getBlobHash(byteLength) { + const hash = crypto.createHash('sha1') + hash.setEncoding('hex') + hash.update(getGitBlobHeader(byteLength)) + return hash +} + +/** + * Compute the git blob hash for a blob from a readable stream of its content. + * + * @function + * @param {number} byteLength + * @param {stream.Readable} stream + * @return {Promise.} hexadecimal SHA-1 hash + */ +exports.fromStream = BPromise.method(function blobHashFromStream( + byteLength, + stream +) { + assert.integer(byteLength, 'blobHash: bad byteLength') + assert.object(stream, 'blobHash: bad stream') + + const hash = getBlobHash(byteLength) + return new BPromise(function (resolve, reject) { + stream.on('end', function () { + hash.end() + resolve(hash.read()) + }) + stream.on('error', reject) + stream.pipe(hash) + }) +}) + +/** + * Compute the git blob hash for a blob with the given string content. + * + * @param {string} string + * @return {string} hexadecimal SHA-1 hash + */ +exports.fromString = function blobHashFromString(string) { + assert.string(string, 'blobHash: bad string') + const hash = getBlobHash(Buffer.byteLength(string)) + hash.update(string, 'utf8') + hash.end() + return hash.read() +} + +/** + * Compute the git blob hash for the content of a file + * + * @param {string} filePath + * @return {string} hexadecimal SHA-1 hash + */ +exports.fromFile = function blobHashFromFile(pathname) { + assert.string(pathname, 'blobHash: bad pathname') + + function getByteLengthOfFile() { + return fs.statAsync(pathname).then(stat => stat.size) + } + + const fromStream = this.fromStream + return getByteLengthOfFile(pathname).then(function (byteLength) { + const stream = fs.createReadStream(pathname) + return fromStream(byteLength, stream) + }) +} diff --git a/services/history-v1/storage/lib/blob_store/index.js b/services/history-v1/storage/lib/blob_store/index.js new file mode 100644 index 0000000000..ae286389b3 --- /dev/null +++ b/services/history-v1/storage/lib/blob_store/index.js @@ -0,0 +1,290 @@ +'use strict' + +const config = require('config') +const fs = require('fs') +const isValidUtf8 = require('utf-8-validate') +const stringToStream = require('string-to-stream') + +const core = require('overleaf-editor-core') +const objectPersistor = require('@overleaf/object-persistor') +const OError = require('@overleaf/o-error') +const Blob = core.Blob +const TextOperation = core.TextOperation +const containsNonBmpChars = core.util.containsNonBmpChars + +const assert = require('../assert') +const blobHash = require('../blob_hash') +const mongodb = require('../mongodb') +const persistor = require('../persistor') +const projectKey = require('../project_key') +const streams = require('../streams') +const postgresBackend = require('./postgres') +const mongoBackend = require('./mongo') + +const GLOBAL_BLOBS = new Map() + +function makeGlobalKey(hash) { + return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}` +} + +function makeProjectKey(projectId, hash) { + return `${projectKey.format(projectId)}/${hash.slice(0, 2)}/${hash.slice(2)}` +} + +async function uploadBlob(projectId, blob, stream) { + const bucket = config.get('blobStore.projectBucket') + const key = makeProjectKey(projectId, blob.getHash()) + await persistor.sendStream(bucket, key, stream, { + contentType: 'application/octet-stream', + }) +} + +function getBlobLocation(projectId, hash) { + if (GLOBAL_BLOBS.has(hash)) { + return { + bucket: config.get('blobStore.globalBucket'), + key: makeGlobalKey(hash), + } + } else { + return { + bucket: config.get('blobStore.projectBucket'), + key: makeProjectKey(projectId, hash), + } + } +} + +/** + * Returns the appropriate backend for the given project id + * + * Numeric ids use the Postgres backend. + * Strings of 24 characters use the Mongo backend. + */ +function getBackend(projectId) { + if (assert.POSTGRES_ID_REGEXP.test(projectId)) { + return postgresBackend + } else if (assert.MONGO_ID_REGEXP.test(projectId)) { + return mongoBackend + } else { + throw new OError('bad project id', { projectId }) + } +} + +async function makeBlobForFile(pathname) { + async function getByteLengthOfFile() { + const stat = await fs.promises.stat(pathname) + return stat.size + } + + async function getHashOfFile(blob) { + const stream = fs.createReadStream(pathname) + const hash = await blobHash.fromStream(blob.getByteLength(), stream) + return hash + } + + const blob = new Blob() + const byteLength = await getByteLengthOfFile() + blob.setByteLength(byteLength) + const hash = await getHashOfFile(blob) + blob.setHash(hash) + return blob +} + +async function getStringLengthOfFile(byteLength, pathname) { + // We have to read the file into memory to get its UTF-8 length, so don't + // bother for files that are too large for us to edit anyway. + if (byteLength > Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) { + return null + } + + // We need to check if the file contains nonBmp or null characters + let data = await fs.promises.readFile(pathname) + if (!isValidUtf8(data)) return null + data = data.toString() + if (data.length > TextOperation.MAX_STRING_LENGTH) return null + if (containsNonBmpChars(data)) return null + if (data.indexOf('\x00') !== -1) return null + return data.length +} + +async function deleteBlobsInBucket(projectId) { + const bucket = config.get('blobStore.projectBucket') + const prefix = `${projectKey.format(projectId)}/` + await persistor.deleteDirectory(bucket, prefix) +} + +async function loadGlobalBlobs() { + const blobs = await mongodb.globalBlobs.find() + for await (const blob of blobs) { + GLOBAL_BLOBS.set(blob._id, { + blob: new Blob(blob._id, blob.byteLength, blob.stringLength), + demoted: Boolean(blob.demoted), + }) + } +} + +/** + * @classdesc + * Fetch and store the content of files using content-addressable hashing. The + * blob store manages both content and metadata (byte and UTF-8 length) for + * blobs. + */ +class BlobStore { + /** + * @constructor + * @param {string} projectId the project for which we'd like to find blobs + */ + constructor(projectId) { + assert.projectId(projectId) + this.projectId = projectId + this.backend = getBackend(this.projectId) + } + + /** + * Set up the initial data structure for a given project + */ + async initialize() { + await this.backend.initialize(this.projectId) + } + + /** + * Write a blob, if one does not already exist, with the given UTF-8 encoded + * string content. + * + * @param {string} string + * @return {Promise.} + */ + async putString(string) { + assert.string(string, 'bad string') + const hash = blobHash.fromString(string) + + const existingBlob = await this._findBlobBeforeInsert(hash) + if (existingBlob != null) { + return existingBlob + } + const newBlob = new Blob(hash, Buffer.byteLength(string), string.length) + // Note: the stringToStream is to work around a bug in the AWS SDK: it won't + // allow Body to be blank. + await uploadBlob(this.projectId, newBlob, stringToStream(string)) + await this.backend.insertBlob(this.projectId, newBlob) + return newBlob + } + + /** + * Write a blob, if one does not already exist, with the given file (usually a + * temporary file). + * + * @param {string} pathname + * @return {Promise.} + */ + async putFile(pathname) { + assert.string(pathname, 'bad pathname') + const newBlob = await makeBlobForFile(pathname) + const existingBlob = await this._findBlobBeforeInsert(newBlob.getHash()) + if (existingBlob != null) { + return existingBlob + } + const stringLength = await getStringLengthOfFile( + newBlob.getByteLength(), + pathname + ) + newBlob.setStringLength(stringLength) + await uploadBlob(this.projectId, newBlob, fs.createReadStream(pathname)) + await this.backend.insertBlob(this.projectId, newBlob) + return newBlob + } + + /** + * Fetch a blob's content by its hash as a UTF-8 encoded string. + * + * @param {string} hash hexadecimal SHA-1 hash + * @return {Promise.} promise for the content of the file + */ + async getString(hash) { + assert.blobHash(hash, 'bad hash') + + const stream = await this.getStream(hash) + const buffer = await streams.readStreamToBuffer(stream) + return buffer.toString() + } + + /** + * Fetch a blob by its hash as a stream. + * + * Note that, according to the AWS SDK docs, this does not retry after initial + * failure, so the caller must be prepared to retry on errors, if appropriate. + * + * @param {string} hash hexadecimal SHA-1 hash + * @return {stream} a stream to read the file + */ + async getStream(hash) { + assert.blobHash(hash, 'bad hash') + + const { bucket, key } = getBlobLocation(this.projectId, hash) + try { + const stream = await persistor.getObjectStream(bucket, key) + return stream + } catch (err) { + if (err instanceof objectPersistor.Errors.NotFoundError) { + throw new Blob.NotFoundError(hash) + } + throw err + } + } + + /** + * Read a blob metadata record by hexadecimal hash. + * + * @param {string} hash hexadecimal SHA-1 hash + * @return {Promise.} + */ + async getBlob(hash) { + assert.blobHash(hash, 'bad hash') + const globalBlob = GLOBAL_BLOBS.get(hash) + if (globalBlob != null) { + return globalBlob.blob + } + const blob = await this.backend.findBlob(this.projectId, hash) + return blob + } + + async getBlobs(hashes) { + assert.array(hashes, 'bad hashes') + const nonGlobalHashes = [] + const blobs = [] + for (const hash of hashes) { + const globalBlob = GLOBAL_BLOBS.get(hash) + if (globalBlob != null) { + blobs.push(globalBlob.blob) + } else { + nonGlobalHashes.push(hash) + } + } + const projectBlobs = await this.backend.findBlobs( + this.projectId, + nonGlobalHashes + ) + blobs.push(...projectBlobs) + return blobs + } + + /** + * Delete all blobs that belong to the project. + */ + async deleteBlobs() { + await Promise.all([ + this.backend.deleteBlobs(this.projectId), + deleteBlobsInBucket(this.projectId), + ]) + } + + async _findBlobBeforeInsert(hash) { + const globalBlob = GLOBAL_BLOBS.get(hash) + if (globalBlob != null && !globalBlob.demoted) { + return globalBlob.blob + } + const blob = await this.backend.findBlob(this.projectId, hash) + return blob + } +} + +module.exports = { BlobStore, loadGlobalBlobs } diff --git a/services/history-v1/storage/lib/blob_store/mongo.js b/services/history-v1/storage/lib/blob_store/mongo.js new file mode 100644 index 0000000000..d2372cbeda --- /dev/null +++ b/services/history-v1/storage/lib/blob_store/mongo.js @@ -0,0 +1,289 @@ +/** + * Mongo backend for the blob store. + * + * Blobs are stored in the projectHistoryBlobs collection. Each project has a + * document in that collection. That document has a "blobs" subdocument whose + * fields are buckets of blobs. The key of a bucket is the first three hex + * digits of the blob hash. The value of the bucket is an array of blobs that + * match the key. + * + * Buckets have a maximum capacity of 8 blobs. When that capacity is exceeded, + * blobs are stored in a secondary collection: the projectHistoryShardedBlobs + * collection. This collection shards blobs between 16 documents per project. + * The shard key is the first hex digit of the hash. The documents are also + * organized in buckets, but the bucket key is made of hex digits 2, 3 and 4. + */ + +const { Blob } = require('overleaf-editor-core') +const { ObjectId, Binary } = require('mongodb') +const assert = require('../assert') +const mongodb = require('../mongodb') + +const MAX_BLOBS_IN_BUCKET = 8 +const DUPLICATE_KEY_ERROR_CODE = 11000 + +/** + * Set up the data structures for a given project. + */ +async function initialize(projectId) { + assert.mongoId(projectId, 'bad projectId') + try { + await mongodb.blobs.insertOne({ + _id: ObjectId(projectId), + blobs: {}, + }) + } catch (err) { + if (err.code !== DUPLICATE_KEY_ERROR_CODE) { + throw err + } + } +} + +/** + * Return blob metadata for the given project and hash. + */ +async function findBlob(projectId, hash) { + assert.mongoId(projectId, 'bad projectId') + assert.blobHash(hash, 'bad hash') + + const bucket = getBucket(hash) + const result = await mongodb.blobs.findOne( + { _id: ObjectId(projectId) }, + { projection: { _id: 0, bucket: `$${bucket}` } } + ) + + if (result?.bucket == null) { + return null + } + + const record = result.bucket.find(blob => blob.h.toString('hex') === hash) + if (record == null) { + if (result.bucket.length >= MAX_BLOBS_IN_BUCKET) { + return await findBlobSharded(projectId, hash) + } else { + return null + } + } + return recordToBlob(record) +} + +/** + * Search in the sharded collection for blob metadata + */ +async function findBlobSharded(projectId, hash) { + const [shard, bucket] = getShardedBucket(hash) + const id = makeShardedId(projectId, shard) + const result = await mongodb.shardedBlobs.findOne( + { _id: id }, + { projection: { _id: 0, blobs: `$${bucket}` } } + ) + if (result?.blobs == null) { + return null + } + const record = result.blobs.find(blob => blob.h.toString('hex') === hash) + return recordToBlob(record) +} + +/** + * Read multiple blob metadata records by hexadecimal hashes. + */ +async function findBlobs(projectId, hashes) { + assert.mongoId(projectId, 'bad projectId') + assert.array(hashes, 'bad hashes: not array') + hashes.forEach(function (hash) { + assert.blobHash(hash, 'bad hash') + }) + + // Build a set of unique buckets + const buckets = new Set(hashes.map(getBucket)) + + // Get buckets from Mongo + const projection = { _id: 0 } + for (const bucket of buckets) { + projection[bucket] = 1 + } + const result = await mongodb.blobs.findOne( + { _id: ObjectId(projectId) }, + { projection } + ) + + if (result?.blobs == null) { + return [] + } + + // Build blobs from the query results + const hashSet = new Set(hashes) + const blobs = [] + for (const bucket of Object.values(result.blobs)) { + for (const record of bucket) { + const hash = record.h.toString('hex') + if (hashSet.has(hash)) { + blobs.push(recordToBlob(record)) + hashSet.delete(hash) + } + } + } + + // If we haven't found all the blobs, look in the sharded collection + if (hashSet.size > 0) { + const shardedBlobs = await findBlobsSharded(projectId, hashSet) + blobs.push(...shardedBlobs) + } + + return blobs +} + +/** + * Search in the sharded collection for blob metadata. + */ +async function findBlobsSharded(projectId, hashSet) { + // Build a map of buckets by shard key + const bucketsByShard = new Map() + for (const hash of hashSet) { + const [shard, bucket] = getShardedBucket(hash) + let buckets = bucketsByShard.get(shard) + if (buckets == null) { + buckets = new Set() + bucketsByShard.set(shard, buckets) + } + buckets.add(bucket) + } + + // Make parallel requests to the shards that might contain the hashes we want + const requests = [] + for (const [shard, buckets] of bucketsByShard.entries()) { + const id = makeShardedId(projectId, shard) + const projection = { _id: 0 } + for (const bucket of buckets) { + projection[bucket] = 1 + } + const request = mongodb.shardedBlobs.findOne({ _id: id }, { projection }) + requests.push(request) + } + const results = await Promise.all(requests) + + // Build blobs from the query results + const blobs = [] + for (const result of results) { + if (result?.blobs == null) { + continue + } + + for (const bucket of Object.values(result.blobs)) { + for (const record of bucket) { + const hash = record.h.toString('hex') + if (hashSet.has(hash)) { + blobs.push(recordToBlob(record)) + } + } + } + } + return blobs +} + +/** + * Add a blob's metadata to the blobs collection after it has been uploaded. + */ +async function insertBlob(projectId, blob) { + assert.mongoId(projectId, 'bad projectId') + const hash = blob.getHash() + const bucket = getBucket(hash) + const record = blobToRecord(blob) + const result = await mongodb.blobs.updateOne( + { + _id: ObjectId(projectId), + $expr: { + $lt: [{ $size: { $ifNull: [`$${bucket}`, []] } }, MAX_BLOBS_IN_BUCKET], + }, + }, + { + $addToSet: { [bucket]: record }, + } + ) + + if (result.matchedCount === 0) { + await insertRecordSharded(projectId, hash, record) + } +} + +/** + * Add a blob's metadata to the sharded blobs collection. + */ +async function insertRecordSharded(projectId, hash, record) { + const [shard, bucket] = getShardedBucket(hash) + const id = makeShardedId(projectId, shard) + await mongodb.shardedBlobs.updateOne( + { _id: id }, + { $addToSet: { [bucket]: record } }, + { upsert: true } + ) +} + +/** + * Delete all blobs for a given project. + */ +async function deleteBlobs(projectId) { + assert.mongoId(projectId, 'bad projectId') + await mongodb.blobs.deleteOne({ _id: ObjectId(projectId) }) + const minShardedId = makeShardedId(projectId, '0') + const maxShardedId = makeShardedId(projectId, 'f') + await mongodb.shardedBlobs.deleteMany({ + _id: { $gte: minShardedId, $lte: maxShardedId }, + }) +} + +/** + * Return the Mongo path to the bucket for the given hash. + */ +function getBucket(hash) { + return `blobs.${hash.slice(0, 3)}` +} + +/** + * Return the shard key and Mongo path to the bucket for the given hash in the + * sharded collection. + */ +function getShardedBucket(hash) { + const shard = hash.slice(0, 1) + const bucket = `blobs.${hash.slice(1, 4)}` + return [shard, bucket] +} + +/** + * Create an _id key for the sharded collection. + */ +function makeShardedId(projectId, shard) { + return new Binary(Buffer.from(`${projectId}0${shard}`, 'hex')) +} + +/** + * Return the Mongo record for the given blob. + */ +function blobToRecord(blob) { + const hash = blob.getHash() + const byteLength = blob.getByteLength() + const stringLength = blob.getStringLength() + return { + h: new Binary(Buffer.from(hash, 'hex')), + b: byteLength, + s: stringLength, + } +} + +/** + * Create a blob from the given Mongo record. + */ +function recordToBlob(record) { + if (record == null) { + return + } + return new Blob(record.h.toString('hex'), record.b, record.s) +} + +module.exports = { + initialize, + findBlob, + findBlobs, + insertBlob, + deleteBlobs, +} diff --git a/services/history-v1/storage/lib/blob_store/postgres.js b/services/history-v1/storage/lib/blob_store/postgres.js new file mode 100644 index 0000000000..9e40c255da --- /dev/null +++ b/services/history-v1/storage/lib/blob_store/postgres.js @@ -0,0 +1,113 @@ +const { Blob } = require('overleaf-editor-core') +const assert = require('../assert') +const knex = require('../knex') + +/** + * Set up the initial data structures for a project + */ +async function initialize(projectId) { + // Nothing to do for Postgres +} + +/** + * Return blob metadata for the given project and hash + */ +async function findBlob(projectId, hash) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + assert.blobHash(hash, 'bad hash') + + const binaryHash = hashToBuffer(hash) + const record = await knex('project_blobs') + .select('hash_bytes', 'byte_length', 'string_length') + .where({ + project_id: projectId, + hash_bytes: binaryHash, + }) + .first() + return recordToBlob(record) +} + +/** + * Read multiple blob metadata records by hexadecimal hashes. + * + * @param {Array.} hashes hexadecimal SHA-1 hashes + * @return {Promise.>} no guarantee on order + */ +async function findBlobs(projectId, hashes) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + assert.array(hashes, 'bad hashes: not array') + hashes.forEach(function (hash) { + assert.blobHash(hash, 'bad hash') + }) + + const binaryHashes = hashes.map(hashToBuffer) + + const records = await knex('project_blobs') + .select('hash_bytes', 'byte_length', 'string_length') + .where('project_id', projectId) + .whereIn('hash_bytes', binaryHashes) + + const blobs = records.map(recordToBlob) + return blobs +} + +/** + * Add a blob's metadata to the blobs table after it has been uploaded. + */ +async function insertBlob(projectId, blob) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + await knex('project_blobs') + .insert(blobToRecord(projectId, blob)) + .onConflict(['project_id', 'hash_bytes']) + .ignore() +} + +/** + * Deletes all blobs for a given project + */ +async function deleteBlobs(projectId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + await knex('project_blobs').where('project_id', projectId).delete() +} + +function blobToRecord(projectId, blob) { + return { + project_id: projectId, + hash_bytes: hashToBuffer(blob.hash), + byte_length: blob.getByteLength(), + string_length: blob.getStringLength(), + } +} + +function recordToBlob(record) { + if (!record) return + return new Blob( + hashFromBuffer(record.hash_bytes), + record.byte_length, + record.string_length + ) +} + +function hashToBuffer(hash) { + if (!hash) return + return Buffer.from(hash, 'hex') +} + +function hashFromBuffer(buffer) { + if (!buffer) return + return buffer.toString('hex') +} + +module.exports = { + initialize, + findBlob, + findBlobs, + insertBlob, + deleteBlobs, +} diff --git a/services/history-v1/storage/lib/chunk_store/errors.js b/services/history-v1/storage/lib/chunk_store/errors.js new file mode 100644 index 0000000000..5f0eba6aac --- /dev/null +++ b/services/history-v1/storage/lib/chunk_store/errors.js @@ -0,0 +1,7 @@ +const OError = require('@overleaf/o-error') + +class ChunkVersionConflictError extends OError {} + +module.exports = { + ChunkVersionConflictError, +} diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js new file mode 100644 index 0000000000..eb3c8ba48d --- /dev/null +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -0,0 +1,331 @@ +'use strict' + +/** + * Manage {@link Chunk} and {@link History} storage. + * + * For storage, chunks are immutable. If we want to update a project with new + * changes, we create a new chunk record and History object and delete the old + * ones. If we compact a project's history, we similarly destroy the old chunk + * (or chunks) and replace them with a new one. This is helpful when using S3, + * because it guarantees only eventual consistency for updates but provides + * stronger consistency guarantees for object creation. + * + * When a chunk record in the database is removed, we save its ID for later + * in the `old_chunks` table, rather than deleting it immediately. This lets us + * use batch deletion to reduce the number of delete requests to S3. + * + * The chunk store also caches data about which blobs are referenced by each + * chunk, which allows us to find unused blobs without loading all of the data + * for all projects from S3. Whenever we create a chunk, we also insert records + * into the `chunk_blobs` table, to help with this bookkeeping. + */ + +const config = require('config') +const OError = require('@overleaf/o-error') +const { Chunk, History, Snapshot } = require('overleaf-editor-core') + +const assert = require('../assert') +const BatchBlobStore = require('../batch_blob_store') +const { BlobStore } = require('../blob_store') +const historyStore = require('../history_store') +const mongoBackend = require('./mongo') +const postgresBackend = require('./postgres') +const { ChunkVersionConflictError } = require('./errors') + +const DEFAULT_DELETE_BATCH_SIZE = parseInt(config.get('maxDeleteKeys'), 10) +const DEFAULT_DELETE_TIMEOUT_SECS = 3000 // 50 minutes +const DEFAULT_DELETE_MIN_AGE_SECS = 86400 // 1 day + +/** + * Create the initial chunk for a project. + */ +async function initializeProject(projectId, snapshot) { + if (projectId != null) { + assert.projectId(projectId, 'bad projectId') + } else { + projectId = await postgresBackend.generateProjectId() + } + + if (snapshot != null) { + assert.instance(snapshot, Snapshot, 'bad snapshot') + } else { + snapshot = new Snapshot() + } + + const blobStore = new BlobStore(projectId) + await blobStore.initialize() + + const backend = getBackend(projectId) + const chunkRecord = await backend.getLatestChunk(projectId) + if (chunkRecord != null) { + throw new AlreadyInitialized(projectId) + } + + const history = new History(snapshot, []) + const chunk = new Chunk(history, 0) + await create(projectId, chunk) + return projectId +} + +/** + * Load the blobs referenced in the given history + */ +async function lazyLoadHistoryFiles(history, batchBlobStore) { + const blobHashes = new Set() + history.findBlobHashes(blobHashes) + + await batchBlobStore.preload(Array.from(blobHashes)) + await history.loadFiles('lazy', batchBlobStore) +} + +/** + * Load the latest Chunk stored for a project, including blob metadata. + * + * @param {number} projectId + * @return {Promise.} + */ +async function loadLatest(projectId) { + assert.projectId(projectId, 'bad projectId') + + const backend = getBackend(projectId) + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + const chunkRecord = await backend.getLatestChunk(projectId) + if (chunkRecord == null) { + throw new Chunk.NotFoundError(projectId) + } + + const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) + const history = History.fromRaw(rawHistory) + await lazyLoadHistoryFiles(history, batchBlobStore) + return new Chunk(history, chunkRecord.startVersion) +} + +/** + * Load the the chunk that contains the given version, including blob metadata. + */ +async function loadAtVersion(projectId, version) { + assert.projectId(projectId, 'bad projectId') + assert.integer(version, 'bad version') + + const backend = getBackend(projectId) + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + + const chunkRecord = await backend.getChunkForVersion(projectId, version) + const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) + const history = History.fromRaw(rawHistory) + await lazyLoadHistoryFiles(history, batchBlobStore) + return new Chunk(history, chunkRecord.endVersion - history.countChanges()) +} + +/** + * Load the chunk that contains the version that was current at the given + * timestamp, including blob metadata. + */ +async function loadAtTimestamp(projectId, timestamp) { + assert.projectId(projectId, 'bad projectId') + assert.date(timestamp, 'bad timestamp') + + const backend = getBackend(projectId) + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + + const chunkRecord = await backend.getChunkForTimestamp(projectId, timestamp) + const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) + const history = History.fromRaw(rawHistory) + await lazyLoadHistoryFiles(history, batchBlobStore) + return new Chunk(history, chunkRecord.endVersion - history.countChanges()) +} + +/** + * Store the chunk and insert corresponding records in the database. + * + * @param {number} projectId + * @param {Chunk} chunk + * @return {Promise.} for the chunkId of the inserted chunk + */ +async function create(projectId, chunk) { + assert.projectId(projectId, 'bad projectId') + assert.instance(chunk, Chunk, 'bad chunk') + + const backend = getBackend(projectId) + const chunkId = await uploadChunk(projectId, chunk) + await backend.confirmCreate(projectId, chunk, chunkId) +} + +/** + * Upload the given chunk to object storage. + * + * This is used by the create and update methods. + */ +async function uploadChunk(projectId, chunk) { + const backend = getBackend(projectId) + const blobStore = new BlobStore(projectId) + + const historyStoreConcurrency = parseInt( + config.get('chunkStore.historyStoreConcurrency'), + 10 + ) + + const rawHistory = await chunk + .getHistory() + .store(blobStore, historyStoreConcurrency) + const chunkId = await backend.insertPendingChunk(projectId, chunk) + await historyStore.storeRaw(projectId, chunkId, rawHistory) + return chunkId +} + +/** + * Extend the project's history by replacing the latest chunk with a new + * chunk. + * + * @param {number} projectId + * @param {number} oldEndVersion + * @param {Chunk} newChunk + * @return {Promise} + */ +async function update(projectId, oldEndVersion, newChunk) { + assert.projectId(projectId, 'bad projectId') + assert.integer(oldEndVersion, 'bad oldEndVersion') + assert.instance(newChunk, Chunk, 'bad newChunk') + + const backend = getBackend(projectId) + const oldChunkId = await getChunkIdForVersion(projectId, oldEndVersion) + const newChunkId = await uploadChunk(projectId, newChunk) + + await backend.confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) +} + +/** + * Find the chunk ID for a given version of a project. + * + * @param {number} projectId + * @param {number} version + * @return {Promise.} + */ +async function getChunkIdForVersion(projectId, version) { + const backend = getBackend(projectId) + const chunkRecord = await backend.getChunkForVersion(projectId, version) + return chunkRecord.id +} + +/** + * Get all of a project's chunk ids + */ +async function getProjectChunkIds(projectId) { + const backend = getBackend(projectId) + const chunkIds = await backend.getProjectChunkIds(projectId) + return chunkIds +} + +/** + * Delete the given chunk from the database. + * + * This doesn't delete the chunk from object storage yet. The old chunks + * collection will do that. + */ +async function destroy(projectId, chunkId) { + const backend = getBackend(projectId) + await backend.deleteChunk(projectId, chunkId) +} + +/** + * Delete all of a project's chunks from the database. + */ +async function deleteProjectChunks(projectId) { + const backend = getBackend(projectId) + await backend.deleteProjectChunks(projectId) +} + +/** + * Delete a given number of old chunks from both the database + * and from object storage. + * + * @param {number} count - number of chunks to delete + * @param {number} minAgeSecs - how many seconds ago must chunks have been + * deleted + * @return {Promise} + */ +async function deleteOldChunks(options = {}) { + const batchSize = options.batchSize ?? DEFAULT_DELETE_BATCH_SIZE + const maxBatches = options.maxBatches ?? Number.MAX_SAFE_INTEGER + const minAgeSecs = options.minAgeSecs ?? DEFAULT_DELETE_MIN_AGE_SECS + const timeout = options.timeout ?? DEFAULT_DELETE_TIMEOUT_SECS + assert.greater(batchSize, 0) + assert.greater(timeout, 0) + assert.greater(maxBatches, 0) + assert.greaterOrEqual(minAgeSecs, 0) + + const timeoutAfter = Date.now() + timeout * 1000 + let deletedChunksTotal = 0 + for (const backend of [postgresBackend, mongoBackend]) { + for (let i = 0; i < maxBatches; i++) { + if (Date.now() > timeoutAfter) { + break + } + const deletedChunks = await deleteOldChunksBatch( + backend, + batchSize, + minAgeSecs + ) + deletedChunksTotal += deletedChunks.length + if (deletedChunks.length !== batchSize) { + // Last batch was incomplete. There probably are no old chunks left + break + } + } + } + return deletedChunksTotal +} + +async function deleteOldChunksBatch(backend, count, minAgeSecs) { + assert.greater(count, 0, 'bad count') + assert.greaterOrEqual(minAgeSecs, 0, 'bad minAgeSecs') + + const oldChunks = await backend.getOldChunksBatch(count, minAgeSecs) + if (oldChunks.length === 0) { + return [] + } + await historyStore.deleteChunks(oldChunks) + await backend.deleteOldChunks(oldChunks.map(chunk => chunk.chunkId)) + return oldChunks +} + +/** + * Returns the appropriate backend for the given project id + * + * Numeric ids use the Postgres backend. + * Strings of 24 characters use the Mongo backend. + */ +function getBackend(projectId) { + if (assert.POSTGRES_ID_REGEXP.test(projectId)) { + return postgresBackend + } else if (assert.MONGO_ID_REGEXP.test(projectId)) { + return mongoBackend + } else { + throw new OError('bad project id', { projectId }) + } +} + +class AlreadyInitialized extends OError { + constructor(projectId) { + super('Project is already initialized', { projectId }) + } +} + +module.exports = { + initializeProject, + loadLatest, + loadAtVersion, + loadAtTimestamp, + create, + update, + destroy, + getChunkIdForVersion, + getProjectChunkIds, + deleteProjectChunks, + deleteOldChunks, + AlreadyInitialized, + ChunkVersionConflictError, +} diff --git a/services/history-v1/storage/lib/chunk_store/mongo.js b/services/history-v1/storage/lib/chunk_store/mongo.js new file mode 100644 index 0000000000..0e58e7a2a4 --- /dev/null +++ b/services/history-v1/storage/lib/chunk_store/mongo.js @@ -0,0 +1,248 @@ +const { ObjectId } = require('mongodb') +const { Chunk } = require('overleaf-editor-core') +const OError = require('@overleaf/o-error') +const assert = require('../assert') +const mongodb = require('../mongodb') +const { ChunkVersionConflictError } = require('./errors') + +const DUPLICATE_KEY_ERROR_CODE = 11000 + +/** + * Get the latest chunk's metadata from the database + */ +async function getLatestChunk(projectId) { + assert.mongoId(projectId, 'bad projectId') + + const record = await mongodb.chunks.findOne( + { projectId: ObjectId(projectId), state: 'active' }, + { sort: { startVersion: -1 } } + ) + if (record == null) { + return null + } + return chunkFromRecord(record) +} + +/** + * Get the metadata for the chunk that contains the given version. + */ +async function getChunkForVersion(projectId, version) { + assert.mongoId(projectId, 'bad projectId') + assert.integer(version, 'bad version') + + const record = await mongodb.chunks.findOne( + { + projectId: ObjectId(projectId), + state: 'active', + startVersion: { $lte: version }, + endVersion: { $gte: version }, + }, + { sort: { startVersion: 1 } } + ) + if (record == null) { + throw new Chunk.VersionNotFoundError(projectId, version) + } + return chunkFromRecord(record) +} + +/** + * Get the metadata for the chunk that contains the version that was current at + * the given timestamp. + */ +async function getChunkForTimestamp(projectId, timestamp) { + assert.mongoId(projectId, 'bad projectId') + assert.date(timestamp, 'bad timestamp') + + const record = await mongodb.chunks.findOne( + { + projectId: ObjectId(projectId), + state: 'active', + endTimestamp: { $gte: timestamp }, + }, + // 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) { + // Couldn't find a chunk that had modifications after the given timestamp. + // Fetch the latest chunk instead. + const chunk = await getLatestChunk(projectId) + if (chunk == null) { + throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp) + } + return chunk + } + + return chunkFromRecord(record) +} + +/** + * Get all of a project's chunk ids + */ +async function getProjectChunkIds(projectId) { + assert.mongoId(projectId, 'bad projectId') + + const cursor = mongodb.chunks.find( + { projectId: ObjectId(projectId), state: 'active' }, + { projection: { _id: 1 } } + ) + return await cursor.map(record => record._id).toArray() +} + +/** + * Insert a pending chunk before sending it to object storage. + */ +async function insertPendingChunk(projectId, chunk) { + assert.mongoId(projectId, 'bad projectId') + assert.instance(chunk, Chunk, 'bad chunk') + + const chunkId = new ObjectId() + await mongodb.chunks.insertOne({ + _id: chunkId, + projectId: ObjectId(projectId), + startVersion: chunk.getStartVersion(), + endVersion: chunk.getEndVersion(), + endTimestamp: chunk.getEndTimestamp(), + state: 'pending', + updatedAt: new Date(), + }) + return chunkId.toString() +} + +/** + * Record that a new chunk was created. + */ +async function confirmCreate(projectId, chunk, chunkId) { + assert.mongoId(projectId, 'bad projectId') + assert.instance(chunk, Chunk, 'bad chunk') + assert.mongoId(chunkId, 'bad chunkId') + + let result + try { + result = await mongodb.chunks.updateOne( + { + _id: ObjectId(chunkId), + projectId: ObjectId(projectId), + state: 'pending', + }, + { $set: { state: 'active', updatedAt: new Date() } } + ) + } catch (err) { + if (err.code === DUPLICATE_KEY_ERROR_CODE) { + throw new ChunkVersionConflictError('chunk start version is not unique', { + projectId, + chunkId, + }) + } else { + throw err + } + } + if (result.matchedCount === 0) { + throw new OError('pending chunk not found', { projectId, chunkId }) + } +} + +/** + * Record that a chunk was replaced by a new one. + */ +async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) { + assert.mongoId(projectId, 'bad projectId') + assert.mongoId(oldChunkId, 'bad oldChunkId') + assert.instance(newChunk, Chunk, 'bad newChunk') + assert.mongoId(newChunkId, 'bad newChunkId') + + const session = mongodb.client.startSession() + try { + await session.withTransaction(async () => { + await deleteChunk(projectId, oldChunkId) + await confirmCreate(projectId, newChunk, newChunkId) + }) + } finally { + await session.endSession() + } +} + +/** + * Delete a chunk. + * + * @param {number} projectId + * @param {number} chunkId + * @return {Promise} + */ +async function deleteChunk(projectId, chunkId) { + assert.mongoId(projectId, 'bad projectId') + assert.mongoId(chunkId, 'bad chunkId') + + await mongodb.chunks.updateOne( + { _id: ObjectId(chunkId), projectId: ObjectId(projectId) }, + { $set: { state: 'deleted', updatedAt: new Date() } } + ) +} + +/** + * Delete all of a project's chunks + */ +async function deleteProjectChunks(projectId) { + assert.mongoId(projectId, 'bad projectId') + + await mongodb.chunks.updateMany( + { projectId: ObjectId(projectId) }, + { $set: { state: 'deleted', updatedAt: new Date() } } + ) +} + +/** + * Get a batch of old chunks for deletion + */ +async function getOldChunksBatch(count, minAgeSecs) { + const maxUpdatedAt = new Date(Date.now() - minAgeSecs * 1000) + const cursor = mongodb.chunks.find( + { + state: { $in: ['deleted', 'pending'] }, + updatedAt: { $lt: maxUpdatedAt }, + }, + { + limit: count, + projection: { _id: 1, projectId: 1 }, + } + ) + return await cursor + .map(record => ({ + chunkId: record._id, + projectId: record.projectId, + })) + .toArray() +} + +/** + * Delete a batch of old chunks from the database + */ +async function deleteOldChunks(chunkIds) { + await mongodb.chunks.deleteMany({ _id: { $in: chunkIds }, state: 'deleted' }) +} + +/** + * Build a chunk metadata object from the database record + */ +function chunkFromRecord(record) { + return { + id: record._id.toString(), + startVersion: record.startVersion, + endVersion: record.endVersion, + } +} + +module.exports = { + getLatestChunk, + getChunkForVersion, + getChunkForTimestamp, + getProjectChunkIds, + insertPendingChunk, + confirmCreate, + confirmUpdate, + deleteChunk, + deleteProjectChunks, + getOldChunksBatch, + deleteOldChunks, +} diff --git a/services/history-v1/storage/lib/chunk_store/postgres.js b/services/history-v1/storage/lib/chunk_store/postgres.js new file mode 100644 index 0000000000..fc154116e2 --- /dev/null +++ b/services/history-v1/storage/lib/chunk_store/postgres.js @@ -0,0 +1,269 @@ +const { Chunk } = require('overleaf-editor-core') +const assert = require('../assert') +const knex = require('../knex') +const { ChunkVersionConflictError } = require('./errors') + +const DUPLICATE_KEY_ERROR_CODE = '23505' + +/** + * Get the latest chunk's metadata from the database + */ +async function getLatestChunk(projectId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + const record = await knex('chunks') + .where('doc_id', projectId) + .orderBy('end_version', 'desc') + .first() + if (record == null) { + return null + } + return chunkFromRecord(record) +} + +/** + * Get the metadata for the chunk that contains the given version. + */ +async function getChunkForVersion(projectId, version) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + const record = await knex('chunks') + .where('doc_id', projectId) + .where('end_version', '>=', version) + .orderBy('end_version') + .first() + if (!record) { + throw new Chunk.VersionNotFoundError(projectId, version) + } + return chunkFromRecord(record) +} + +/** + * Get the metadata for the chunk that contains the version that was current at + * the given timestamp. + */ +async function getChunkForTimestamp(projectId, timestamp) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + // This query will find the latest chunk after the timestamp (query orders + // in reverse chronological order), OR the latest chunk + // This accounts for the case where the timestamp is ahead of the chunk's + // timestamp and therefore will not return any results + const whereAfterEndTimestampOrLatestChunk = knex.raw( + 'end_timestamp >= ? ' + + 'OR id = ( ' + + 'SELECT id FROM chunks ' + + 'WHERE doc_id = ? ' + + 'ORDER BY end_version desc LIMIT 1' + + ')', + [timestamp, projectId] + ) + + const record = await knex('chunks') + .where('doc_id', projectId) + .where(whereAfterEndTimestampOrLatestChunk) + .orderBy('end_version') + .first() + if (!record) { + throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp) + } + return chunkFromRecord(record) +} + +/** + * Build a chunk metadata object from the database record + */ +function chunkFromRecord(record) { + return { + id: record.id, + startVersion: record.start_version, + endVersion: record.end_version, + } +} + +/** + * Get all of a project's chunk ids + */ +async function getProjectChunkIds(projectId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + const records = await knex('chunks').select('id').where('doc_id', projectId) + return records.map(record => record.id) +} + +/** + * Insert a pending chunk before sending it to object storage. + */ +async function insertPendingChunk(projectId, chunk) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + const result = await knex.first( + knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid") + ) + const chunkId = result.chunkid + await knex('pending_chunks').insert({ + id: chunkId, + doc_id: projectId, + end_version: chunk.getEndVersion(), + start_version: chunk.getStartVersion(), + end_timestamp: chunk.getEndTimestamp(), + }) + return chunkId +} + +/** + * Record that a new chunk was created. + */ +async function confirmCreate(projectId, chunk, chunkId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + await knex.transaction(async tx => { + await Promise.all([ + _deletePendingChunk(tx, projectId, chunkId), + _insertChunk(tx, projectId, chunk, chunkId), + ]) + }) +} + +/** + * Record that a chunk was replaced by a new one. + */ +async function confirmUpdate(projectId, oldChunkId, newChunk, newChunkId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + + await knex.transaction(async tx => { + await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId }) + await Promise.all([ + _deletePendingChunk(tx, projectId, newChunkId), + _insertChunk(tx, projectId, newChunk, newChunkId), + ]) + }) +} + +async function _deletePendingChunk(tx, projectId, chunkId) { + await tx('pending_chunks') + .where({ + doc_id: projectId, + id: chunkId, + }) + .del() +} + +async function _insertChunk(tx, projectId, chunk, chunkId) { + const startVersion = chunk.getStartVersion() + const endVersion = chunk.getEndVersion() + try { + await tx('chunks').insert({ + id: chunkId, + doc_id: projectId, + start_version: startVersion, + end_version: endVersion, + end_timestamp: chunk.getEndTimestamp(), + }) + } catch (err) { + if (err.code === DUPLICATE_KEY_ERROR_CODE) { + throw new ChunkVersionConflictError( + 'chunk start or end version is not unique', + { projectId, chunkId, startVersion, endVersion } + ) + } + throw err + } +} + +/** + * Delete a chunk. + * + * @param {number} projectId + * @param {number} chunkId + * @return {Promise} + */ +async function deleteChunk(projectId, chunkId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + assert.integer(chunkId, 'bad chunkId') + + await _deleteChunks(knex, { doc_id: projectId, id: chunkId }) +} + +/** + * Delete all of a project's chunks + */ +async function deleteProjectChunks(projectId) { + projectId = parseInt(projectId, 10) + assert.integer(projectId, 'bad projectId') + assert.integer(projectId, 'bad projectId') + + knex.transaction(async tx => { + await _deleteChunks(knex, { doc_id: projectId }) + }) +} + +async function _deleteChunks(tx, whereClause) { + const rows = await tx('chunks').returning('*').where(whereClause).del() + + const oldChunks = rows.map(row => ({ + doc_id: row.doc_id, + chunk_id: row.id, + start_version: row.start_version, + end_version: row.end_version, + end_timestamp: row.end_timestamp, + deleted_at: tx.fn.now(), + })) + await tx('old_chunks').insert(oldChunks) +} + +/** + * Get a batch of old chunks for deletion + */ +async function getOldChunksBatch(count, minAgeSecs) { + const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000) + const records = await knex('old_chunks') + .whereNull('deleted_at') + .orWhere('deleted_at', '<', maxDeletedAt) + .orderBy('chunk_id') + .limit(count) + return records.map(oldChunk => ({ + projectId: oldChunk.doc_id.toString(), + chunkId: oldChunk.chunk_id, + })) +} + +/** + * Delete a batch of old chunks from the database + */ +async function deleteOldChunks(chunkIds) { + await knex('old_chunks').whereIn('chunk_id', chunkIds).del() +} + +/** + * Generate a new project id + */ +async function generateProjectId() { + const record = await knex.first( + knex.raw("nextval('docs_id_seq'::regclass)::integer as doc_id") + ) + return record.doc_id.toString() +} + +module.exports = { + getLatestChunk, + getChunkForVersion, + getChunkForTimestamp, + getProjectChunkIds, + insertPendingChunk, + confirmCreate, + confirmUpdate, + deleteChunk, + deleteProjectChunks, + getOldChunksBatch, + deleteOldChunks, + generateProjectId, +} diff --git a/services/history-v1/storage/lib/hash_check_blob_store.js b/services/history-v1/storage/lib/hash_check_blob_store.js new file mode 100644 index 0000000000..5f233d5109 --- /dev/null +++ b/services/history-v1/storage/lib/hash_check_blob_store.js @@ -0,0 +1,30 @@ +const Blob = require('overleaf-editor-core').Blob +const blobHash = require('./blob_hash') +const BPromise = require('bluebird') + +// We want to simulate applying all of the operations so we can return the +// resulting hashes to the caller for them to check. To do this, we need to be +// able to take the lazy files in the final snapshot, fetch their content, and +// compute the new content hashes. We don't, however, need to actually store +// that content; we just need to get the hash. +function HashCheckBlobStore(realBlobStore) { + this.realBlobStore = realBlobStore +} + +HashCheckBlobStore.prototype.getString = BPromise.method( + function hashCheckBlobStoreGetString(hash) { + return this.realBlobStore.getString(hash) + } +) + +HashCheckBlobStore.prototype.putString = BPromise.method( + function hashCheckBlobStorePutString(string) { + return new Blob( + blobHash.fromString(string), + Buffer.byteLength(string), + string.length + ) + } +) + +module.exports = HashCheckBlobStore diff --git a/services/history-v1/storage/lib/history_store.js b/services/history-v1/storage/lib/history_store.js new file mode 100644 index 0000000000..95231a2cf6 --- /dev/null +++ b/services/history-v1/storage/lib/history_store.js @@ -0,0 +1,132 @@ +'use strict' + +const BPromise = require('bluebird') +const core = require('overleaf-editor-core') + +const config = require('config') +const path = require('path') + +const OError = require('@overleaf/o-error') +const objectPersistor = require('@overleaf/object-persistor') + +const assert = require('./assert') +const persistor = require('./persistor') +const projectKey = require('./project_key') +const streams = require('./streams') + +const Chunk = core.Chunk + +const BUCKET = config.get('chunkStore.bucket') + +class LoadError extends OError { + constructor(projectId, chunkId) { + super('HistoryStore: failed to load chunk history', { projectId, chunkId }) + this.projectId = projectId + this.chunkId = chunkId + } +} +HistoryStore.LoadError = LoadError + +class StoreError extends OError { + constructor(projectId, chunkId) { + super('HistoryStore: failed to store chunk history', { projectId, chunkId }) + this.projectId = projectId + this.chunkId = chunkId + } +} +HistoryStore.StoreError = StoreError + +function getKey(projectId, chunkId) { + return path.join(projectKey.format(projectId), projectKey.pad(chunkId)) +} + +/** + * Store and retreive raw {@link History} objects from bucket. Mainly used via the + * {@link ChunkStore}. + * + * Histories are stored as gzipped JSON blobs, keyed on the project ID and the + * ID of the Chunk that owns the history. The project ID is currently redundant, + * but I think it might help in future if we have to shard on project ID, and + * it gives us some chance of reconstructing histories even if there is a + * problem with the chunk metadata in the database. + * + * @class + */ +function HistoryStore() {} + +/** + * Load the raw object for a History. + * + * @param {number} projectId + * @param {number} chunkId + * @return {Promise.} + */ +HistoryStore.prototype.loadRaw = function historyStoreLoadRaw( + projectId, + chunkId +) { + assert.projectId(projectId, 'bad projectId') + assert.chunkId(chunkId, 'bad chunkId') + + const key = getKey(projectId, chunkId) + + return BPromise.resolve() + .then(() => persistor.getObjectStream(BUCKET, key)) + .then(streams.gunzipStreamToBuffer) + .then(buffer => JSON.parse(buffer)) + .catch(err => { + if (err instanceof objectPersistor.Errors.NotFoundError) { + throw new Chunk.NotPersistedError(projectId) + } + throw new HistoryStore.LoadError(projectId, chunkId).withCause(err) + }) +} + +/** + * Compress and store a {@link History}. + * + * @param {number} projectId + * @param {number} chunkId + * @param {Object} rawHistory + * @return {Promise} + */ +HistoryStore.prototype.storeRaw = function historyStoreStoreRaw( + projectId, + chunkId, + rawHistory +) { + assert.projectId(projectId, 'bad projectId') + assert.chunkId(chunkId, 'bad chunkId') + assert.object(rawHistory, 'bad rawHistory') + + const key = getKey(projectId, chunkId) + const stream = streams.gzipStringToStream(JSON.stringify(rawHistory)) + + return BPromise.resolve() + .then(() => + persistor.sendStream(BUCKET, key, stream, { + contentType: 'application/json', + contentEncoding: 'gzip', + }) + ) + .catch(err => { + throw new HistoryStore.StoreError(projectId, chunkId).withCause(err) + }) +} + +/** + * Delete multiple chunks from bucket. Expects an Array of objects with + * projectId and chunkId properties + * @param {Array} chunks + * @return {Promise} + */ +HistoryStore.prototype.deleteChunks = function historyDeleteChunks(chunks) { + return BPromise.all( + chunks.map(chunk => { + const key = getKey(chunk.projectId, chunk.chunkId) + return persistor.deleteObject(BUCKET, key) + }) + ) +} + +module.exports = new HistoryStore() diff --git a/services/history-v1/storage/lib/knex.js b/services/history-v1/storage/lib/knex.js new file mode 100644 index 0000000000..5cdc85e2ab --- /dev/null +++ b/services/history-v1/storage/lib/knex.js @@ -0,0 +1,6 @@ +'use strict' + +const env = process.env.NODE_ENV || 'development' + +const knexfile = require('../../knexfile') +module.exports = require('knex')(knexfile[env]) diff --git a/services/history-v1/storage/lib/metrics.js b/services/history-v1/storage/lib/metrics.js new file mode 100644 index 0000000000..906a1224d1 --- /dev/null +++ b/services/history-v1/storage/lib/metrics.js @@ -0,0 +1,3 @@ +const metrics = require('@overleaf/metrics') +metrics.configure({ appName: 'history-v1' }) +module.exports = metrics diff --git a/services/history-v1/storage/lib/mongodb.js b/services/history-v1/storage/lib/mongodb.js new file mode 100644 index 0000000000..4e6d098d2d --- /dev/null +++ b/services/history-v1/storage/lib/mongodb.js @@ -0,0 +1,12 @@ +const config = require('config') +const { MongoClient } = require('mongodb') + +const client = new MongoClient(config.mongo.uri) +const db = client.db() + +const chunks = db.collection('projectHistoryChunks') +const blobs = db.collection('projectHistoryBlobs') +const globalBlobs = db.collection('projectHistoryGlobalBlobs') +const shardedBlobs = db.collection('projectHistoryShardedBlobs') + +module.exports = { client, db, chunks, blobs, globalBlobs, shardedBlobs } diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js new file mode 100644 index 0000000000..b661a4818c --- /dev/null +++ b/services/history-v1/storage/lib/persist_changes.js @@ -0,0 +1,171 @@ +/** @module */ +'use strict' + +const _ = require('lodash') +const BPromise = require('bluebird') + +const core = require('overleaf-editor-core') +const Chunk = core.Chunk +const History = core.History + +const assert = require('./assert') +const chunkStore = require('./chunk_store') + +function countChangeBytes(change) { + // Note: This is not quite accurate, because the raw change may contain raw + // file info (or conceivably even content) that will not be included in the + // actual stored object. + return Buffer.byteLength(JSON.stringify(change.toRaw())) +} + +function totalChangeBytes(changes) { + return changes.length ? _(changes).map(countChangeBytes).sum() : 0 +} + +// provide a simple timer function +function Timer() { + this.t0 = process.hrtime() +} +Timer.prototype.elapsed = function () { + const dt = process.hrtime(this.t0) + const timeInMilliseconds = (dt[0] + dt[1] * 1e-9) * 1e3 + return timeInMilliseconds +} + +/** + * Break the given set of changes into zero or more Chunks according to the + * provided limits and store them. + * + * Some other possible improvements: + * 1. This does a lot more JSON serialization than it has to. We may know the + * JSON for the changes before we call this function, so we could in that + * case get the byte size of each change without doing any work. Even if we + * don't know it initially, we could save some computation by caching this + * info rather than recomputing it many times. TBD whether it is worthwhile. + * 2. We don't necessarily have to fetch the latest chunk in order to determine + * that it is full. We could store this in the chunk metadata record. It may + * be worth distinguishing between a Chunk and its metadata record. The + * endVersion may be better suited to the metadata record. + * + * @param {string} projectId + * @param {Array.} allChanges + * @param {Object} limits + * @param {number} clientEndVersion + * @return {Promise.} + */ +module.exports = function persistChanges( + projectId, + allChanges, + limits, + clientEndVersion +) { + assert.projectId(projectId) + assert.array(allChanges) + assert.maybe.object(limits) + assert.integer(clientEndVersion) + + let currentChunk + // currentSnapshot tracks the latest change that we're applying; we use it to + // check that the changes we are persisting are valid. + let currentSnapshot + let originalEndVersion + let changesToPersist + + limits = limits || {} + _.defaults(limits, { + changeBucketMinutes: 60, + maxChanges: 2500, + maxChangeBytes: 5 * 1024 * 1024, + maxChunkChanges: 2000, + maxChunkChangeBytes: 5 * 1024 * 1024, + maxChunkChangeTime: 5000, // warn if total time for changes in a chunk takes longer than this + }) + + function checkElapsedTime(timer) { + const timeTaken = timer.elapsed() + if (timeTaken > limits.maxChunkChangeTime) { + console.log('warning: slow chunk', projectId, timeTaken) + } + } + + function fillChunk(chunk, changes) { + let totalBytes = totalChangeBytes(chunk.getChanges()) + let changesPushed = false + while (changes.length > 0) { + if (chunk.getChanges().length >= limits.maxChunkChanges) break + const changeBytes = countChangeBytes(changes[0]) + if (totalBytes + changeBytes > limits.maxChunkChangeBytes) break + const changesToFill = changes.splice(0, 1) + currentSnapshot.applyAll(changesToFill, { strict: true }) + chunk.pushChanges(changesToFill) + totalBytes += changeBytes + changesPushed = true + } + return changesPushed + } + + function extendLastChunkIfPossible() { + return chunkStore.loadLatest(projectId).then(function (latestChunk) { + currentChunk = latestChunk + originalEndVersion = latestChunk.getEndVersion() + if (originalEndVersion !== clientEndVersion) { + throw new Chunk.ConflictingEndVersion( + clientEndVersion, + originalEndVersion + ) + } + + currentSnapshot = latestChunk.getSnapshot().clone() + const timer = new Timer() + currentSnapshot.applyAll(latestChunk.getChanges()) + + if (!fillChunk(currentChunk, changesToPersist)) return + checkElapsedTime(timer) + + return chunkStore.update(projectId, originalEndVersion, currentChunk) + }) + } + + function createNewChunksAsNeeded() { + if (changesToPersist.length === 0) return + + const endVersion = currentChunk.getEndVersion() + const history = new History(currentSnapshot.clone(), []) + const chunk = new Chunk(history, endVersion) + const timer = new Timer() + if (fillChunk(chunk, changesToPersist)) { + checkElapsedTime(timer) + currentChunk = chunk + return chunkStore.create(projectId, chunk).then(createNewChunksAsNeeded) + } + throw new Error('failed to fill empty chunk') + } + + function isOlderThanMinChangeTimestamp(change) { + return change.getTimestamp().getTime() < limits.minChangeTimestamp + } + + function isOlderThanMaxChangeTimestamp(change) { + return change.getTimestamp().getTime() < limits.maxChangeTimestamp + } + + const oldChanges = _.filter(allChanges, isOlderThanMinChangeTimestamp) + const anyTooOld = _.some(oldChanges, isOlderThanMaxChangeTimestamp) + const tooManyChanges = oldChanges.length > limits.maxChanges + const tooManyBytes = totalChangeBytes(oldChanges) > limits.maxChangeBytes + + if (anyTooOld || tooManyChanges || tooManyBytes) { + changesToPersist = oldChanges + const numberOfChangesToPersist = oldChanges.length + return extendLastChunkIfPossible() + .then(createNewChunksAsNeeded) + .then(function () { + return { + numberOfChangesPersisted: numberOfChangesToPersist, + originalEndVersion, + currentChunk, + } + }) + } + return BPromise.resolve(null) +} diff --git a/services/history-v1/storage/lib/persistor.js b/services/history-v1/storage/lib/persistor.js new file mode 100644 index 0000000000..d24af7d292 --- /dev/null +++ b/services/history-v1/storage/lib/persistor.js @@ -0,0 +1,26 @@ +const _ = require('lodash') +const config = require('config') +const metrics = require('./metrics') +const objectPersistor = require('@overleaf/object-persistor') + +const persistorConfig = _.cloneDeep(config.get('persistor')) + +function convertKey(key, convertFn) { + if (_.has(persistorConfig, key)) { + _.update(persistorConfig, key, convertFn) + } +} + +convertKey('s3.signedUrlExpiryInMs', s => parseInt(s, 10)) +convertKey('s3.httpOptions.timeout', s => parseInt(s, 10)) +convertKey('s3.maxRetries', s => parseInt(s, 10)) +convertKey('s3.pathStyle', s => s === 'true') +convertKey('gcs.unlockBeforeDelete', s => s === 'true') +convertKey('gcs.unsignedUrls', s => s === 'true') +convertKey('gcs.signedUrlExpiryInMs', s => parseInt(s, 10)) +convertKey('gcs.deleteConcurrency', s => parseInt(s, 10)) +convertKey('fallback.buckets', s => JSON.parse(s || '{}')) + +persistorConfig.Metrics = metrics + +module.exports = objectPersistor(persistorConfig) diff --git a/services/history-v1/storage/lib/project_archive.js b/services/history-v1/storage/lib/project_archive.js new file mode 100644 index 0000000000..d6c51be0bd --- /dev/null +++ b/services/history-v1/storage/lib/project_archive.js @@ -0,0 +1,118 @@ +'use strict' + +const Archive = require('archiver') +const BPromise = require('bluebird') +const fs = require('fs') + +const core = require('overleaf-editor-core') +const Snapshot = core.Snapshot +const OError = require('@overleaf/o-error') + +const assert = require('./assert') + +// The maximum safe concurrency appears to be 1. +// https://github.com/overleaf/issues/issues/1909 +const FETCH_CONCURRENCY = 1 // number of files to fetch at once +const DEFAULT_ZIP_TIMEOUT = 25000 // ms + +class DownloadError extends OError { + constructor(hash) { + super(`ProjectArchive: blob download failed: ${hash}`, { hash }) + } +} +ProjectArchive.DownloadError = DownloadError + +class ArchiveTimeout extends OError { + constructor() { + super('ProjectArchive timed out') + } +} +ProjectArchive.ArchiveTimeout = ArchiveTimeout + +/** + * @constructor + * @param {Snapshot} snapshot + * @param {?number} timeout in ms + * @classdesc + * Writes the project snapshot to a zip file. + */ +function ProjectArchive(snapshot, timeout) { + assert.instance(snapshot, Snapshot) + this.snapshot = snapshot + this.timeout = timeout || DEFAULT_ZIP_TIMEOUT +} + +/** + * Write zip archive to the given file path. + * + * @param {BlobStore} blobStore + * @param {string} zipFilePath + */ +ProjectArchive.prototype.writeZip = function projectArchiveToZip( + blobStore, + zipFilePath +) { + const snapshot = this.snapshot + const timeout = this.timeout + + const startTime = process.hrtime() + const archive = new Archive('zip') + + // Convert elapsed seconds and nanoseconds to milliseconds. + function findElapsedMilliseconds() { + const elapsed = process.hrtime(startTime) + return elapsed[0] * 1e3 + elapsed[1] * 1e-6 + } + + function addFileToArchive(pathname) { + if (findElapsedMilliseconds() > timeout) { + throw new ProjectArchive.ArchiveTimeout() + } + + const file = snapshot.getFile(pathname) + return file.load('eager', blobStore).then(function () { + const content = file.getContent() + if (content === null) { + return streamFileToArchive(pathname, file).catch(function (err) { + throw new ProjectArchive.DownloadError(file.getHash()).withCause(err) + }) + } else { + archive.append(content, { name: pathname }) + } + }) + } + + function streamFileToArchive(pathname, file) { + return new BPromise(function (resolve, reject) { + blobStore + .getStream(file.getHash()) + .then(stream => { + stream.on('error', reject) + stream.on('end', resolve) + archive.append(stream, { name: pathname }) + }) + .catch(reject) + }) + } + + const addFilesToArchiveAndFinalize = BPromise.map( + snapshot.getFilePathnames(), + addFileToArchive, + { concurrency: FETCH_CONCURRENCY } + ).then(function () { + archive.finalize() + }) + + const streamArchiveToFile = new BPromise(function (resolve, reject) { + archive.on('error', reject) + + const stream = fs.createWriteStream(zipFilePath) + stream.on('error', reject) + stream.on('finish', resolve) + archive.pipe(stream) + }) + + return BPromise.join(streamArchiveToFile, addFilesToArchiveAndFinalize) +} + +module.exports = ProjectArchive diff --git a/services/history-v1/storage/lib/project_key.js b/services/history-v1/storage/lib/project_key.js new file mode 100644 index 0000000000..e84f024e06 --- /dev/null +++ b/services/history-v1/storage/lib/project_key.js @@ -0,0 +1,23 @@ +const _ = require('lodash') +const path = require('path') + +// +// The advice in http://docs.aws.amazon.com/AmazonS3/latest/dev/ +// request-rate-perf-considerations.html is to avoid sequential key prefixes, +// so we reverse the project ID part of the key as they suggest. +// +function format(projectId) { + const prefix = naiveReverse(pad(projectId)) + return path.join(prefix.slice(0, 3), prefix.slice(3, 6), prefix.slice(6)) +} + +function pad(number) { + return _.padStart(number, 9, '0') +} + +function naiveReverse(string) { + return string.split('').reverse().join('') +} + +exports.format = format +exports.pad = pad diff --git a/services/history-v1/storage/lib/streams.js b/services/history-v1/storage/lib/streams.js new file mode 100644 index 0000000000..e6adff7ba2 --- /dev/null +++ b/services/history-v1/storage/lib/streams.js @@ -0,0 +1,100 @@ +/** + * Promises are promises and streams are streams, and ne'er the twain shall + * meet. + * @module + */ +'use strict' + +const BPromise = require('bluebird') +const zlib = require('zlib') +const stringToStream = require('string-to-stream') + +function promiseWriteStreamFinish(writeStream) { + return new BPromise(function (resolve, reject) { + writeStream.on('finish', resolve) + writeStream.on('error', reject) + }) +} + +function promisePipe(readStream, writeStream) { + readStream.pipe(writeStream) + return promiseWriteStreamFinish(writeStream) +} + +/** + * Pipe a read stream to a write stream. The promise resolves when the write + * stream finishes. + * + * @function + * @param {stream.Readable} readStream + * @param {stream.Writable} writeStream + * @return {Promise} + */ +exports.promisePipe = promisePipe + +function readStreamToBuffer(readStream) { + return new BPromise(function (resolve, reject) { + const buffers = [] + readStream.on('readable', function () { + while (true) { + const buffer = this.read() + if (!buffer) { + break + } + buffers.push(buffer) + } + }) + readStream.on('end', function () { + resolve(Buffer.concat(buffers)) + }) + readStream.on('error', reject) + }) +} + +/** + * Create a promise for the result of reading a stream to a buffer. + * + * @function + * @param {stream.Readable} readStream + * @return {Promise.} + */ +exports.readStreamToBuffer = readStreamToBuffer + +function gunzipStreamToBuffer(readStream) { + const gunzip = zlib.createGunzip() + const gunzipStream = readStream.pipe(gunzip) + return new BPromise(function (resolve, reject) { + const buffers = [] + gunzipStream.on('data', function (buffer) { + buffers.push(buffer) + }) + gunzipStream.on('end', function () { + resolve(Buffer.concat(buffers)) + }) + readStream.on('error', reject) + gunzipStream.on('error', reject) + }) +} + +/** + * Create a promise for the result of un-gzipping a stream to a buffer. + * + * @function + * @param {stream.Readable} readStream + * @return {Promise.} + */ +exports.gunzipStreamToBuffer = gunzipStreamToBuffer + +function gzipStringToStream(string) { + const gzip = zlib.createGzip() + return stringToStream(string).pipe(gzip) +} + +/** + * Create a write stream that gzips the given string. + * + * @function + * @param {string} string + * @return {stream.Writable} + */ +exports.gzipStringToStream = gzipStringToStream diff --git a/services/history-v1/storage/lib/temp.js b/services/history-v1/storage/lib/temp.js new file mode 100644 index 0000000000..719e0767a6 --- /dev/null +++ b/services/history-v1/storage/lib/temp.js @@ -0,0 +1,25 @@ +/* + * Taken from renderer/app/helpers/temp.js with minor cosmetic changes. + * Promisify the temp package. The temp package provides a 'track' feature + * that automatically cleans up temp files at process exit, but that is not + * very useful. They also provide a method to trigger cleanup, but that is not + * safe for concurrent use. So, we use a disposer to unlink the file. + */ + +const BPromise = require('bluebird') +const fs = BPromise.promisifyAll(require('fs')) +const temp = BPromise.promisifyAll(require('temp')) + +exports.open = function (affixes) { + return temp.openAsync(affixes).disposer(function (fileInfo) { + fs.closeAsync(fileInfo.fd) + .then(() => { + return fs.unlinkAsync(fileInfo.path) + }) + .catch(function (err) { + if (err.code !== 'ENOENT') { + throw err + } + }) + }) +} diff --git a/services/history-v1/storage/lib/zip_store.js b/services/history-v1/storage/lib/zip_store.js new file mode 100644 index 0000000000..ed5121ed8a --- /dev/null +++ b/services/history-v1/storage/lib/zip_store.js @@ -0,0 +1,134 @@ +'use strict' + +const BPromise = require('bluebird') +const config = require('config') +const fs = require('fs') +const path = require('path') + +const OError = require('@overleaf/o-error') +const objectPersistor = require('@overleaf/object-persistor') + +const assert = require('./assert') +const { BlobStore } = require('./blob_store') +const persistor = require('./persistor') +const ProjectArchive = require('./project_archive') +const projectKey = require('./project_key') +const temp = require('./temp') + +const BUCKET = config.get('zipStore.bucket') + +function getZipKey(projectId, version) { + return path.join( + projectKey.format(projectId), + version.toString(), + 'project.zip' + ) +} + +/** + * Store a zip of a given version of a project in bucket. + * + * @class + */ +class ZipStore { + /** + * Generate signed link to access the zip file. + * + * @param {number} projectId + * @param {number} version + * @return {string} + */ + async getSignedUrl(projectId, version) { + assert.projectId(projectId, 'bad projectId') + assert.integer(version, 'bad version') + + const key = getZipKey(projectId, version) + return await persistor.getRedirectUrl(BUCKET, key) + } + + /** + * Generate a zip of the given snapshot. + * + * @param {number} projectId + * @param {number} version + * @param {Snapshot} snapshot + */ + async storeZip(projectId, version, snapshot) { + assert.projectId(projectId, 'bad projectId') + assert.integer(version, 'bad version') + assert.object(snapshot, 'bad snapshot') + + const zipKey = getZipKey(projectId, version) + + if (await isZipPresent()) return + + await BPromise.using(temp.open('zip'), async tempFileInfo => { + await zipSnapshot(tempFileInfo.path, snapshot) + await uploadZip(tempFileInfo.path) + }) + + // If the file is already there, we don't need to build the zip again. If we + // just HEAD the file, there's a race condition, because the zip files + // automatically expire. So, we try to copy the file from itself to itself, + // and if it fails, we know the file didn't exist. If it succeeds, this has + // the effect of re-extending its lifetime. + async function isZipPresent() { + try { + await persistor.copyObject(BUCKET, zipKey, zipKey) + return true + } catch (error) { + if (!(error instanceof objectPersistor.Errors.NotFoundError)) { + console.error( + 'storeZip: isZipPresent: unexpected error (except in dev): %s', + error + ) + } + return false + } + } + + async function zipSnapshot(tempPathname, snapshot) { + const blobStore = new BlobStore(projectId) + const zipTimeoutMs = parseInt(config.get('zipStore.zipTimeoutMs'), 10) + const archive = new ProjectArchive(snapshot, zipTimeoutMs) + try { + await archive.writeZip(blobStore, tempPathname) + } catch (err) { + throw new ZipStore.CreationError(projectId, version).withCause(err) + } + } + + async function uploadZip(tempPathname, snapshot) { + const stream = fs.createReadStream(tempPathname) + try { + await persistor.sendStream(BUCKET, zipKey, stream, { + contentType: 'application/zip', + }) + } catch (err) { + throw new ZipStore.UploadError(projectId, version).withCause(err) + } + } + } +} + +class CreationError extends OError { + constructor(projectId, version) { + super(`Zip creation failed for ${projectId} version ${version}`, { + projectId, + version, + }) + } +} +ZipStore.CreationError = CreationError + +class UploadError extends OError { + constructor(projectId, version) { + super(`Zip upload failed for ${projectId} version ${version}`, { + projectId, + version, + }) + } +} +ZipStore.UploadError = UploadError + +module.exports = new ZipStore() diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/01-create-blob-hashes-table.sql b/services/history-v1/storage/scripts/global-blobs-db-cleanup/01-create-blob-hashes-table.sql new file mode 100644 index 0000000000..05e04784b8 --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/01-create-blob-hashes-table.sql @@ -0,0 +1,14320 @@ +CREATE TABLE global_blob_hashes (hash_bytes bytea NOT NULL); +INSERT INTO global_blob_hashes VALUES + ('\x0002bcb29ab63cf63ea2a1184ac8fba8ad4ea85c'), + ('\x0003f8a3f3fdd378689a2b43a11645b7efab8fb3'), + ('\x000523b5135f7906791457f4d16af222daa46d81'), + ('\x000f7a0870f80279092351a62eb56b0d4ef76a61'), + ('\x00111e44854715b956d320009ce922c1aec0deb9'), + ('\x001178cfaa6539c8543a5940487ea13035ff3f4b'), + ('\x001341838be85823b4b1dbb6d05eb7cf8cae0df2'), + ('\x002147ba75e3ca66acc5a2db75a87e68c0d35b5f'), + ('\x0025e578e3aee1d8422df2baf2a8351e7bd252d5'), + ('\x0025f88e875bea45df7ebcb3fc6a035cdcfd1215'), + ('\x00309e45f488298149435a122b2de54682b0d1e2'), + ('\x0031cd62555947223cdaf48143a1c8faea181818'), + ('\x00321999b116755745f860848df5c02a46058a81'), + ('\x003cb2a8fe94990d726e7bcfd26c0a8127f37957'), + ('\x003f597aee7f69007664056b751a1cf02c20dda1'), + ('\x0042ea088fe39b2081fb3f07e9b2374128b267eb'), + ('\x004c3d06ffa20db01ed0d252edaab7742b4f9a95'), + ('\x004d63ff674404768d30de548d29483c867ee4cd'), + ('\x0050d3b0ba7d2a679a506964ade1b937436b98c6'), + ('\x005286233b4bce6ad0c0410be6aa3453fc6e913e'), + ('\x0056efbd609735712934e342450e4eef71e3d4fb'), + ('\x0066416583fa180b48503a5b590b99192860aa79'), + ('\x0067528a0634f0ae369a8c79956cd35524ad9dba'), + ('\x006c51f843374eab631493f02b06c554bf353fa5'), + ('\x007256a0c038ba0ad54d89b9b16887b24025d79a'), + ('\x0079e81766e6a62fe8903b2b13eff435dd8ce6e5'), + ('\x007a10e8b2547dfbb1742a4dacfccfa6edf0439c'), + ('\x007c52f10813305f1cc1ebcb0f32a5152c7d4320'), + ('\x008367616456f828e316fa5bd0388a8fb785b77f'), + ('\x0098f7b57075ae747b166646d42ceeb492da5599'), + ('\x009ba6e0f66a72cca1f4aadd5b3302bd4be7f5f9'), + ('\x009d53db689e7ec30b1431927433b1e377cbfcee'), + ('\x00a97b9e818f06c62eb6f1d2e26e6db5545f7053'), + ('\x00af802b66ccea4bd5fd26fad87f7f5c8c2ce265'), + ('\x00b1e1c78ac81a1dea01540232d49c813b63a759'), + ('\x00b597bd97a407d6c1b2da3828bab674ab1028b4'), + ('\x00ba1d31eb6e05f339005d02e8a196adafc8a9a4'), + ('\x00c50f568cab2ee52463bfba144c5d197128851e'), + ('\x00c9323f5a8d299bf596f409799f475f573d6cec'), + ('\x00cb3d2169a23b995bfe3de12354d07bc3c8ad7a'), + ('\x00cbcd4d1bce83a301fe3c23b51a08695a15f312'), + ('\x00d35648f8bb4ffd38fda8e4dfd88b0c64d9d1e5'), + ('\x00d45bf167505f106b89fb08671e4137aa890de3'), + ('\x00d541e8c43fd26276d24db0a17394caf3c86b88'), + ('\x00d7e3c061705c18c275f9556db792e3d38d9786'), + ('\x00db1d3c7254add7ad491c99899e36c2082d0368'), + ('\x00e5521132d65f8a2bc96f6a0411552dce27a883'), + ('\x00e6d6d53c706fe7c08a9fa02917497ff0574d19'), + ('\x00e7324096f3502f90da446a0da48e214f3369cf'), + ('\x00eb807029fbad2ce159193a6f0973700a7e7920'), + ('\x00f53cff5f50f51deafa18690ab4d9d8a826148d'), + ('\x00fbdb3662cd0d6002848c3186a28bb2a3359cce'), + ('\x00fc30636ab28039aca58a0993861050d946caa0'), + ('\x010c5f023cd050a1b5dbee5493036f20da79df67'), + ('\x011c123c620bb137b7e45b052b10a12085d18eb4'), + ('\x011e8c12452fe515b395ebb97cf732fb7cab3ff3'), + ('\x0126e37416bb49353f26841fba65a2b63972a779'), + ('\x0129ac8ed95c97949ea61e0ba69d6893be524a05'), + ('\x012dcd25c687a53c235de61d4fd6d111747a0b1d'), + ('\x013857d219e5b84a298ed1043f7d215c3befd5c2'), + ('\x013cfcab5216eefc9cfa5414834dc2d2b6076f4a'), + ('\x01426f558712caa574163575157411447c43daaa'), + ('\x0146bcf5bc55340fc4243c74dba2dd12d24b07cc'), + ('\x0150e77308b734f28a0d647eb03b1b22413b342f'), + ('\x01514983e6e74b20f1356bb166174d945ce2eb5b'), + ('\x015285953f6ddcf4470842ec0273833df231a641'), + ('\x0155d241b3065933e4cb78a992650e2c04d028db'), + ('\x015ccb14b9cf7ac6dab7fe3ec8d231fd1f3fce1f'), + ('\x0174a7b6b2f5d49a68cb03a1d1dcacafa0a77b2d'), + ('\x01777bd4404c0e62a1e44eb0d76347eb3e519fbf'), + ('\x017cfb443e51909114c214ed89647873fc7ef382'), + ('\x01811fb4e10b8115ca25604eca6080a5a931af04'), + ('\x0187f9d33de42335798e0b8246a25b09c64e5ec5'), + ('\x0194fba7c500606453112d68089bd3e550328f74'), + ('\x0195d42ae479ed9527f04c204c3f0c40045dc738'), + ('\x019ca07bb01a664ce61a9bc80e7fb8f12fba84e1'), + ('\x01a51dc7d04301ccbb98b88931edf76bcedcdfa6'), + ('\x01a89f7e73acce78c7accfdfe738c9bd7c01857d'), + ('\x01b2d98566fc53591c64133ad2465e797e471b86'), + ('\x01b334a3ca5ef49354be5de0f3f6e6fc314c56ce'), + ('\x01b6b259b94508a25da019329508fd3839f2051a'), + ('\x01b8457ad7ba46cee30683a2ee12abd26ef33f10'), + ('\x01be74110a7497182820a82281a21d8389887846'), + ('\x01c0dab906abfe8f0a4f4248b998e97a38d53502'), + ('\x01c80917548a3d847638a90ad08b31470a8db66d'), + ('\x01cc9854cd638c968aa31ab214e7ee3a94148f91'), + ('\x01d0f5bdf7695b3d6ec6f43cfb6cfbc6438af1bc'), + ('\x01dbf51cb7cb0d9291c47fa07fce308f94aa5cca'), + ('\x01eceae29193e9c2287b312987465e722ada4f6d'), + ('\x01ed6ead5962ca3febf955ba704b34c6466f4cfb'), + ('\x01eef40c1353ecb6b76d95d642171851b080b0a7'), + ('\x01f735a6f58e7b278393a55ccca8b62f3cfd1862'), + ('\x01fc91007061f339fb10983adb8c4d30f366203d'), + ('\x02020ec9648330cfe3923e3c3451c10e77f3e32a'), + ('\x02022cd1b13dd2514adfd5ad7be6fc0910752fa1'), + ('\x020299631a0ee2ed2792d271cdc5a80b055051b4'), + ('\x02050cba99a42cf5f2629207df48a2e64fc12abf'), + ('\x020588f768a515eb8da744812adf0d4bd17114b6'), + ('\x020916c6581f5e846e67c9ca3679d4b34360c8d8'), + ('\x020c4f09330504209c9edeaf1af1ea4ea5c962ee'), + ('\x020f4d75a7259924a0496e9ef514f0388f09b271'), + ('\x02144d28910833b77a98b6299012881694f58ef5'), + ('\x021b49f74992502cee66ada8650e3ccaf68f3917'), + ('\x02284cb5fb07eace3866a8d16656fcd88c176367'), + ('\x0229a9d03a0c33295340c95d464de8f1d5e97bc9'), + ('\x0231bb284b927936c82516f01b24e4d05c8d3331'), + ('\x02322c3162335e1c44b85b2d363ad4c938aab044'), + ('\x0233b1a4906f27244bd3f23ea511faa78a40d451'), + ('\x02388d7fb3d4414da87f535ac120f081b399d1b4'), + ('\x023da72cd18f2319f4afdccc4f79da20a559e8df'), + ('\x0240aba349d512ae0b8eb7382c26ba634807bd24'), + ('\x02455c15192b293936ed8e27eecf8383cabf522b'), + ('\x024745c88fe855c6c8540badd2d4b05b8a2016b3'), + ('\x0248da97307110e61ca6e48164e3662f2dd11e62'), + ('\x02493e7658699e49a4b02371142317fda8c6b4af'), + ('\x024bfab11983844b7d685ecf774dcb11af94afd3'), + ('\x024c5cf33ce75b2504e61b6f0512ea889f7df9db'), + ('\x024d5130039023726f8ba5a1ab48b42f1de784be'), + ('\x0251ab27cf867ec9dfb1f0d93c84b4662e668d67'), + ('\x0257b4fe747acf1e8584089beeb5256b5330ca7b'), + ('\x026b67c7e344979fa7171eba1a72e83198dea585'), + ('\x026f841a425b5dc4c3e9e5efa8157425c9dc3794'), + ('\x026fc481b135cdf7386ae0d14a08046795b269c9'), + ('\x0273af6ba917c0f9f5e1a836ecb88f6fbb2ecef1'), + ('\x02748d4ae22dfc9a93e69555d2d8ebbb1f37bd9f'), + ('\x027dd181fb145ee6ff2a03231ca40e4e3270a9b8'), + ('\x02828718078500fc15ebb244ec5ef09166e73480'), + ('\x02881a567ee2248d506166ae2182bb84c65deb97'), + ('\x02888c275e2d41b1213446e432bb0d2d606fd51c'), + ('\x02899c619149f488ddf6e8d1e786ba24147409f0'), + ('\x0295a5ba698f3bd7af36e3258ee7f0b33094154c'), + ('\x029cd1dd853d6b212a9c571c11967cf74330cc3a'), + ('\x029d6936c86fb21484e90479402ae96c409f2e03'), + ('\x029d7d780ea75c77d5b0a6f88862b61e99e70012'), + ('\x02a698c22cd3c1adbefa674997be2444f702880d'), + ('\x02b1dd8738b6322f111b44f9e5b98f06f536bddb'), + ('\x02b3464b5925a78f09bab96f6f5df278d093b905'), + ('\x02b69b4fc8ddc5102ce2023c5347152619e92fa2'), + ('\x02bb307bd2670ed0bab10f2029e78ab6afd6750d'), + ('\x02bed0adb604dbd438d8b9f28b1d32fa9f8753bd'), + ('\x02c0c9f9abd453b4df7e4cb836d550d24ebd45da'), + ('\x02c3d6ac89b22b58d5606ce28712cc65c62edac9'), + ('\x02d4ba6bf2822c9121c786b08cdf4a5cf59b2a46'), + ('\x02d78b937c3a0c20f8e5e7fdf6f0c54dd70ac8a0'), + ('\x02d8013ee2954696e6013a2db9d70121e4ddad6d'), + ('\x02d906792ae8bccfc558a30c5f862407ae50acc7'), + ('\x02e670a22726512b58951381b06f1e96461a0add'), + ('\x02e8c995b5b10516688319d37183368ce4ce5995'), + ('\x02ea4f434516377f6198d7eb4a8a0eff101e8e2b'), + ('\x02efe47ab2103913ccdb69cc46c0681bd7faeebc'), + ('\x02f1569823afdc7a279c8f1fc5567e7a3d35214b'), + ('\x02fdc89398788d1e277d7b59f617f782c7c45c70'), + ('\x030039a38cd8ea34f683fd752af53f03dc8a1067'), + ('\x030076bfda268a04902bceb7906039baa8460cb5'), + ('\x0308fc869680014ef3740515f4504d8766924dd1'), + ('\x0309b9c4d38b61dbccebbdcbdf0545fbec451ee9'), + ('\x0309c9eafaa1f874f972c21c200161d7da63d2ae'), + ('\x030c33a87d8de5dd9848959d975b2fe03be6fe67'), + ('\x030d63e37f76904e38a1e5f295cab17f9a9c0a46'), + ('\x0313c454139eb2d7eb2281ced0be78973ee95004'), + ('\x0314f9427bde3744955de9eeacf904964637cbb9'), + ('\x03168dd8a4800756eaa98bb05e6ee4c8c24f3d60'), + ('\x03174f371a164a47bb7761c4b338b1eb925f1d99'), + ('\x031b02aced1ee1e7c9a789e2d328fcbe6790ed01'), + ('\x031eee72c36a24ed9dad56b6a735529f3993a241'), + ('\x03264d8239431d794fa79ebc6a3cdf62496d2973'), + ('\x032cfa705cb479900e22aad8a6c570f569fbfb56'), + ('\x03346a80b27a53f8e116f5e01eed15aafb67d154'), + ('\x03361f6a40fbe4bf55c1855770edd2b5c15eb8cc'), + ('\x033a64e91de22118b571c0b43680b0eea8d2e3e3'), + ('\x033c171804901d5ca433d9041ecb840494c879c1'), + ('\x033c385d0c6389ab19b281c56ede48ecca289227'), + ('\x033f0c862e242086d238a6f3b731f5efbf822105'), + ('\x0342674234711f14c29da1a251887dfcafa66847'), + ('\x0345400a372eb73d1905a4f826aed48d3d60ee8b'), + ('\x034b63756c7579e3dbe22f358633e906a2cb6a1b'), + ('\x034d83f96baf177d921c0fc38a2c7eca7f458fc8'), + ('\x0350e06d43552a71f7fa3b8e7913f741e296173e'), + ('\x03561223bb21951444f18e88d995d1179fa3980c'), + ('\x035c5baa849c1ca6a303faa88d6862966a8d4ff4'), + ('\x035e9bcbee9815b02fd44455d665e61f9bdbd756'), + ('\x0364174083a4002aad0cf638ed415ec817c25fec'), + ('\x036c45a24cc508847bd7af191ff0226ee93b87fc'), + ('\x036ed3106f99b582d8c88cef1c23dd91f4b899a8'), + ('\x036f9e24ec707a8e9b20efc47f1d010777cb3e73'), + ('\x0371b41681da8f315f72ec2f8344daab4c23330f'), + ('\x0383717c8b6253ddcde86714a561bb48c39f5280'), + ('\x03843ac02cdfc0de537d1ac88b8f380fdc4070bb'), + ('\x03873cda44d924f136df2bbde420d22553fe4780'), + ('\x038d722b1395fae5b3451e608d7ed61fe1d920c7'), + ('\x03972b56fe0df9e09081218525f7ce56eebce207'), + ('\x0398666e9ddcad3d4e68fa0dedbe914bf614fdb5'), + ('\x03a3f11dab138c2bdb163a5342a87790b5aa8c72'), + ('\x03a6d38561757c6d11364747f919e2131b675dd7'), + ('\x03afcacef6f9e812705c63f67ee6b51429a78235'), + ('\x03b461eb6679407536fec4a5ec5f854e73ef606a'), + ('\x03c17155fefd756b2647b44f19e7d14e75099c51'), + ('\x03c191282c15ee966b4eafde44d6cde62816a10f'), + ('\x03c1cf9d7fdc10c57d67cae4103c495f0006bbf0'), + ('\x03c3188fc6daade5dcefbecb476c71e309ffd6b6'), + ('\x03c63ebad3ee0fe21382b98689d93bcb4ff0df8f'), + ('\x03c690eec3dc8b536e7970518b8073af23bd85c2'), + ('\x03c89188ea3cf6b2a012325c1f37aecc514c201e'), + ('\x03ca151a361f4b03c8a5b51b3b05c35bc1921630'), + ('\x03ced1a5cdbc77556cf68bcccc151dfe3874717c'), + ('\x03cf1c0aeb1729ffc1c6aeaa679260877f89d845'), + ('\x03da92a9f5a5b8c4a0f35d13e83dcf4d57384ed8'), + ('\x03e0b68cefa6f5abedb58cefec0a0cc6e85c6253'), + ('\x03e57511cfecdff8970fb0d53ae91855935600a7'), + ('\x03eff2c1783428ef9d6a1f51f73e8036d97eacf5'), + ('\x03fb542875b4ef7b9133f5e1a67af4b2e27faa73'), + ('\x040102af3e52fa1ceb3d8575975ccc659cf65bd1'), + ('\x0402ac900d946203545af3e17c3e6b710c2fca28'), + ('\x04031e41ee23d0c49dfb5f44a1158ee193bed003'), + ('\x0404fe8609ae92ed84e5d95ebc881ffbcf4ea933'), + ('\x0409c2c8e93137029f9de5f965d64d4b8637ce71'), + ('\x0411d3c6820874a38d36fa8e033b5a9850d78020'), + ('\x0415921b0dbfbc5c082f9f48a73e30833a1bb42a'), + ('\x04194147f71f71efca20b606779a64cb3d1466bb'), + ('\x041ae551724550f7d17a0c018559e1c04c6fc80a'), + ('\x041c2e4207502164235783036d07e6b2f709f694'), + ('\x0431abd51b98c7b3542f6de44cde58cc5c02e3a9'), + ('\x0433daa19bdf27b018dd76432597b6cb132a86fb'), + ('\x04346a1228c00ed33cc4d0c6a0445f247b113416'), + ('\x0439096ab607f5cbb48c49b609c2c0478f13e6e7'), + ('\x043ecc268bd5d295ae21a19dc5862e82abb902c1'), + ('\x0443178d186d59e3df415b529d936356eba29d70'), + ('\x044a4753a02cc244ecb5614c794f457bc4630354'), + ('\x044d8880699b82883408e991f7cc54c87a18335e'), + ('\x044dde929745d48d13601b572a0f586728ebf0a4'), + ('\x044e8896500678342232918da5cc3da4946af9f0'), + ('\x0454aa4e9750b994c2e552b490a004781cedbd9a'), + ('\x045526ad3f0981a0c090c35ed3154c5eb52cae22'), + ('\x045b0aefa2475fbc5b6139dfcbeabfbc39221ea1'), + ('\x04603150a01a7bf239e00732ace30496baaffe8f'), + ('\x04687c47a5e9d35c5d61077460d3fd3cc7bdb727'), + ('\x04750868af72916449754ea00654e81f1b44d3b6'), + ('\x0478c4ff0f9932fe9c942af364e0b16da8d2b71b'), + ('\x047beca0adc54d7750c54af702e854215eefcf07'), + ('\x04830e7e1b1b82d07efac042cacef6079c076fa2'), + ('\x048d3c255a3d0ac6810e984ae137eebe57e0c425'), + ('\x04976b28928cdfab18009ff9dc2c8c0999c501b9'), + ('\x04a1d5245f3678246a65bacd584d0983822a3851'), + ('\x04a8275ec18161de8909a1653c4ddf1807b05844'), + ('\x04a8d84cfb4b27f7e7ef8cfd7cdcf74ca2eb6fca'), + ('\x04ad305b755f7af07705a71f932d599b7119e342'), + ('\x04ae0152240a9d7de845ce223a0cc803d033a280'), + ('\x04afa95de4ef3612a8ab372c491d149361815f64'), + ('\x04b4f412281d12f25e72a10c230c8c46f0795c23'), + ('\x04b77fdecd42817abcfac278c2b3d64fd173fdf9'), + ('\x04c3c9142f0bc3852ad30889bacf05d039d1ced1'), + ('\x04c5bd0227959078fd34ecb1f120042a16468015'), + ('\x04ca2824d8d91966605a9ab8a37631e239724277'), + ('\x04cc67ddc33f1b31ae62ec3c0cd443ba0c1dfdd5'), + ('\x04ce21813ec13a7157b1fb3071d5f69ea8864e8d'), + ('\x04d1bbc4597237633cf7c28486f0980d7e8acfe2'), + ('\x04d3fedcdb4555807b0158cfbf5eabd51b5ba505'), + ('\x04d5ab5efb5af71b0a33da0af9051ebf643fc341'), + ('\x04d6e5d68cda1d846e51eda87f24e411e9b90ab4'), + ('\x04d843410127e1be42245961b910be811e935fbb'), + ('\x04d8c0b2800dfe7aad2ebfd3fdace8241543b4b3'), + ('\x04e139e9a044540dc819e77af3d99ac9e25d5b83'), + ('\x04e3ad8f8c275e96e7f3c839e1540a36c0459728'), + ('\x04e3c7cf677257b43c3381a3db36233cf5cf8721'), + ('\x04ea8efb1367727b081dea87e63818be0a4d02f0'), + ('\x04ec472ef658bbc581afac408d8fa142d4fead55'), + ('\x04f7f0cf370ca47b0ccfaeea6b954878c4b2f769'), + ('\x04fadfbec4d19b71e2a95753f0a4152316b48bb4'), + ('\x04fd7fbf1cf005a13586f43dd845bc0ed04220fe'), + ('\x04fd9b717411241f003e3fb299ad8f113f5379b3'), + ('\x05024614ca5a145f6812b7e8bfa163c5ea603d4e'), + ('\x0504b25dedcebdc07b6214fd03bd529a4bc77136'), + ('\x050710a10a2ddbbc35553c3446a512c0354bc4e7'), + ('\x050d04693f1f7af67c3f51a28148dbc523241ff7'), + ('\x051125deb27b382e1cdb08e7bf2beb4f422f8c9b'), + ('\x05113ac2b3581774440d69d16937647a9aec0047'), + ('\x0523922e95e1ada1e6f799492f55c9ed1cf92901'), + ('\x052a5fc5937b7e3ebef35003c2b09884ab80a952'), + ('\x052e934c71dae1c84506a03efb5bd0ec667c0411'), + ('\x0530a5b021803106210aa92af8e2b92a66731965'), + ('\x054c8877261401487016db0a5a2eb0bca1e76eec'), + ('\x054dd530f3d687e755f7df2725df9b25d7e38f1b'), + ('\x055617dff76ff174197b0fbca95acf06f61280a6'), + ('\x055b466208de1c4091012b3a6c798c5133037441'), + ('\x055f19936c9883af5194a4a7593e45ba4226a969'), + ('\x05601a82f3d625fe059ec406b516dcf1fc41a2f2'), + ('\x056c0ba903d81b1563a1b309623411f2b61990f9'), + ('\x0573181f4069653e871ff3cae4650bf432512127'), + ('\x0575c72fc021ed6d133b201a4e8fe43db250733a'), + ('\x0579f456bd26aaa321b2a74ca85f0e03cc37d83b'), + ('\x057e884cf462bfdfe66c2353b6efa6337931d2f8'), + ('\x0580633a1b3ec06613c68eaa03ad8e518d0958e0'), + ('\x0582692a8040c30b114663562d4c2d620b0f20c8'), + ('\x058caea9cec84245f5d14820a66bf35a309dc34d'), + ('\x05920dd9fddfd88a7364e18fca6e5fa04a2a687a'), + ('\x05925ec0011f3034afb1140389cdf95676a3eb00'), + ('\x0594882854daacfe89c3f3bf58a041fc22316c36'), + ('\x0595d8255fe6c68f30d38ec2711a9897ab700658'), + ('\x05969b18deb5a084fa0ba26866c912a30516c357'), + ('\x059eddd8cefe7af5df2965b0636abfd64142da3a'), + ('\x05a2f299571545fbbab6aeb2a59fb29b05c2486f'), + ('\x05a3cd718d4053e9e902a44f67864c581f9f3dda'), + ('\x05aff4d863cc6342d81f1c0b30fff4a38bd60e6a'), + ('\x05b7eec04dbb3d78b251f5fb270fa15a2f8549bd'), + ('\x05d8d0125b4993c35e4aabd6b0cc7d76f4b5b6a4'), + ('\x05e41c405d17dad31ad25722ae57e71da956425c'), + ('\x05f4383de2a48d6cc6a5931b58f72b042fda425f'), + ('\x05f9dcf2e296cc85923505857bd24393946d51c9'), + ('\x05fc9b7edc5997740b9f46a531d6375c1687049c'), + ('\x05fdbe6d7686deba7766cb08d43626de20a4f50b'), + ('\x05ff3a02f6283189045d3501967afe4fe6366feb'), + ('\x05ffff5d2cade75b635dc6213a1de431312477e6'), + ('\x0601683c9f67c7cd0401b3734bace92a4506984a'), + ('\x0602bc0df0a93010438306cb024517d6fd1662ba'), + ('\x060d225fa834621d3ea987c272e6d4ab4e1bcf0e'), + ('\x060f337172152eb5e9121c3e697ec016ed63ec8f'), + ('\x06112ab06886c0a751cee1f599e45ecb2df3b666'), + ('\x0617c43c6fb80d4bd2360f499e7fcf0730d250bd'), + ('\x06188f473a029a1f64633fe5bf559d0e7c47afac'), + ('\x061a8234d2ef738590f3a0d01a61ec4d61a945f1'), + ('\x0627166b623668215a47fad3848d50a4583ad587'), + ('\x06297d9937e88eb254121787ea0f4121c83b3127'), + ('\x062c024a3429d6fb739ac336324cc5ef31a9dea7'), + ('\x062c93953cbd9968d65cd61990bf8114afb4926a'), + ('\x062ed2e9726cf6dd9901672674e4ac958c2559cd'), + ('\x062ef8bc974269b1f644af72fd33d07d4e31cd85'), + ('\x063f9564054742d4a57979d4aff7969abf5af9de'), + ('\x063f9d82a55ca6f2767a62cddf0ce9f4edc02582'), + ('\x06509b785473bf333659a972a845676c6f552540'), + ('\x06574d0a1fda5c244ab0404b1fcaa0dd95b7c527'), + ('\x0657fb0f4ab09254a454ea27bc9e08b637053886'), + ('\x065a98ff1e400313a99dbca51e19fe5bd9c3a5f1'), + ('\x0660b050d27995e4f3080977db8743706d0d7369'), + ('\x06632fd7f5f19f6bd4d61c4d3ded0549b59516aa'), + ('\x0664dc407f1d7818f06dff7eda244e2a2e3d52fc'), + ('\x0668f931945175ca8535db25cc27fa603920cc3c'), + ('\x066b6d4e538f19b5f8099c401031979b75b9a745'), + ('\x066ea5f0fd98be48e13b253eb0dda9ffcffc11db'), + ('\x06718ed6c1fc325e5ed747634d5d254057fa9a84'), + ('\x06738674d4e35bd845bc08e5564e0066c0cf2ecf'), + ('\x0678839bb58897658a00ba22c4f66f94b95aadfd'), + ('\x0679841e1ab42648014bbcc31aab351e64c4068e'), + ('\x067dda54cf810187576fa1cf104f77c1c05720da'), + ('\x0687fd1e3e06a617e20f7597522b267864db20ac'), + ('\x068d8589098df7f9fd130fc324285d5a9602cf44'), + ('\x068f2ea8c49d9ea0910482c2fb72248e2ca9f8f7'), + ('\x068ff0e74b03fd7b9185515f1d01bde4d66c72fc'), + ('\x069802618801661c3715cbfad2b1557495e1b83d'), + ('\x069fc85bd7d7cb98eb1cfaf4b6be03de4e4c2f07'), + ('\x06a1cd580ffdc61e01fd5ac61f54943394dcc122'), + ('\x06a5bb0f9d09243ce6bf7d2085bba41abfbf8f2e'), + ('\x06a7a6117d3efdf23ec2c63f046aa32fc13d752a'), + ('\x06a7c9ab528f769fa3dde9dd1d5831aaa50e1354'), + ('\x06ad00011a727e9ff263ee685d974021cbf6185e'), + ('\x06b0cfef8094132fb4cc4fcba1f43fa2cad92403'), + ('\x06b0f85bd893fdafcf12884d4c11236b3a11bb3b'), + ('\x06b29dfafcc6e06d154fc0c7be3c68fd7c0e909f'), + ('\x06bca61ef1b2edea7aeb80a11a4168864fd74204'), + ('\x06c1905a0069f10595041f5dc4f70e9d60a2f104'), + ('\x06c5b540bd78339f51d0e7923f69205fbf789ab2'), + ('\x06c5c5861f6621dd44a9e697d85d463196f582f4'), + ('\x06cd27148ee551bff90fe2b52d97fd9e0062f0a8'), + ('\x06cef5ff777a14a56fe70af934fef9c9f62df580'), + ('\x06d280e934de0ef274f37798e94127418965a670'), + ('\x06d4033e5c77da843b142752a7d66add3106e636'), + ('\x06e0e2f6523554efca40e54d8c4501b0c704074f'), + ('\x06e250c7b3adec158cf18ce473f406f67bf00049'), + ('\x06e2a03901e3df1636d13e47d7de9f18e4b369a9'), + ('\x06e60dc78a21607092905fa1573cf31db1315dfd'), + ('\x06e9d63ecee942ae63876037c10108a12c304a32'), + ('\x06f32c281d6f23a37b3f40e7b19ba2dba03a16a2'), + ('\x06f3bdc9c00dd0a1cfdbe40cee9d220cb22d1778'), + ('\x06f4b48d7aac4758900d7e2d7e1d284be1da6159'), + ('\x06f50168cf4640d43c2d78be85e61a096e02dd8f'), + ('\x06f5785da5b536749712cb7ffa4e4668356ef376'), + ('\x06f76ba06b8577fcf4d90de81ffa6efa8d9ce3cd'), + ('\x06f96da5ef0d1e6a259f182f57aa7b27414e656d'), + ('\x06f9c58123993fa447aaee1d82fc23a6b0e42abf'), + ('\x06fc805d103aefe05ba1769a1b08a16039cbc542'), + ('\x0703f1b41b90732f4145d289cfe5cbee51d8aece'), + ('\x070465a82800f00b1ee250cc356ac03cde9178cf'), + ('\x0708c517ca41e638ed1a79dfb139dbbf3e5e8c92'), + ('\x070c76ed2641f62970a7c105e87c78196570e8ee'), + ('\x0712a0356fa8c2d1fae06326b8c497995998cee6'), + ('\x0712c3f6c249205a562769eea13f34b720a8a09e'), + ('\x0718ccc52dea057fcabc3f24fc2baee6a73fcc98'), + ('\x072b6a11d602d74cbf9d27e5f8006ecc0db51091'), + ('\x073ab95b88ecf561fc64ac5e681d6160d89dbe44'), + ('\x07410a3754a5e3f7abd56ef3984f46b606cea083'), + ('\x0742fa1845ee72c764a779b28e21ce38183a0c65'), + ('\x0743ae5de69b0b24c234e1ea66a8968150cfb53f'), + ('\x0747c67faf391ea5b2d2d21cb520cef6a2ac90d6'), + ('\x0748aa4b58a95fa7654b6e4a8ed531baf61dc5a1'), + ('\x074a54201584b101ee817484120452352c024d19'), + ('\x074d7ddd1b9962769b5bddb628b9c0e4511d41fe'), + ('\x0753b81e25eb7edd190611df7c05775cef7b1344'), + ('\x0755da4d59e1ca1ac2e242a2d7ce96aaa6243283'), + ('\x075641a2caf407e796945fdeaa874b0a5357c09d'), + ('\x075a0d9f8eb91e5d797baa1077bfde98eb1c6824'), + ('\x075dc83cdafb69fbe902792d1410650dfa191ff3'), + ('\x075f8512e27e72a7f4b43797138d7492af17dd95'), + ('\x07610d602b5028d3fbf3c87cd34a23f4e2e6d066'), + ('\x0761c773c079222a5e983fa10e5276a49fa01635'), + ('\x0770059b12ed2740a55d96238cdccc86e8a72777'), + ('\x0773fd921a4dfbf3ac00b5bf2a086b96fcb4f9ab'), + ('\x0777cdce522ebb770830490842fe14090119cb8f'), + ('\x077e911ec5d4c7423cc9ebeb47ceff7c1303d910'), + ('\x078fc04ffa1b8d4bf0d0d5df722788cf2b195c37'), + ('\x07949dc1103b24c85b24fe388c3c351fd956e7e8'), + ('\x0795a31353a8694569f7bbc8c15f3fddaea1e824'), + ('\x07a1946173a49a83d54cae2f885fccd5519ed2e4'), + ('\x07a5fd368d4795427095a1305d5a7ebd9554946a'), + ('\x07a72880ecf6f982aebd400289cc04b1acb1d137'), + ('\x07a9260cad8b4b69c45e589832595203dadfdbea'), + ('\x07ae73e55a449746b7e376c57765739a565c0529'), + ('\x07b69444f08315f4fdc92fa15404903213adb4b4'), + ('\x07b6f9ae6db76074988b931168b4212bf89fa80d'), + ('\x07bc8eb3bec2bca8fb3745313a31d4e36be03234'), + ('\x07c9583f05b21257a4baf6ae40595c622c69c83d'), + ('\x07d537a73cd0329fd9068e37fd60863133dcac01'), + ('\x07d67e26e22354194798329ada91b9502dd925f4'), + ('\x07daf3adeb81b961a747b6ec2e6970023d8f21f3'), + ('\x07df451789f6e92a026c77e3554313c5c1582a8f'), + ('\x07e4d7d6bce9a17b57aa2a5cf2cc72a6a8f32f58'), + ('\x07e4f14096382d4851536526329fee8bb255eaec'), + ('\x07e779d54505fae49fdb33cf0894daef08e2cf79'), + ('\x07e8874e40b45a29ae4a74f234c49d8da82814e0'), + ('\x07ed906c9f2de07822f1d6266423895fbc6ed3de'), + ('\x07edd82b4951c6f321ceeca501ecc90709d875a4'), + ('\x07ef4380c1dc8e0102bcff151785d2cfb6856c49'), + ('\x07f380cab59796eaaaebea429da7bfd91feeab5c'), + ('\x07f7ea438bcc6a391a81fe96834d76b1722b72d9'), + ('\x07f9f287fb465f7fc70ea55a7f3ab8a9196ca14d'), + ('\x07fa25158221f991ea68bf8494f18842b57877ae'), + ('\x07fbd158cfa4f3a6e643629420e9bb9f5d53a182'), + ('\x07fd1d7ddc6a5121bd8c5756443fba6a6de2ece4'), + ('\x07fd88812a165e7fa3713592a2f79e763257e9de'), + ('\x07fe3ae4317c2cf939128ee7c0b8053a468474b2'), + ('\x080723f6ba1d5c27743d154ea87f065e9e73c824'), + ('\x081671b6aa225a2b8431248df67a32c91c7bfd6d'), + ('\x0818d5c01d33479fb8cc7741aaee673fb3670695'), + ('\x081952dd477b53623044f8d86de07136f9c58f4e'), + ('\x081ce34749400e841b4e2802a8d52edda2d6e136'), + ('\x081ee72f6e9f03babc56d9e2bd3ee334ac4868ce'), + ('\x08218ab946b6aa7f1775f8afc3167e922e6897dc'), + ('\x083823c998328c049448680831a437b3fa0d18d3'), + ('\x083909a9cd7192f3a5d924d8ce0cccc0e07e1a24'), + ('\x083f711df219e1e6060aac7912a050af24182256'), + ('\x084106dbdfef76c6763be54e9b3916f23369b3e8'), + ('\x084a7d1f8d13f74530095ab463c4b0b3bd809d97'), + ('\x08526192f81c8bed23b769b80906819a62e5b74c'), + ('\x08575f171c5f4ad1e07cc10fcdab761fdf51aedb'), + ('\x085a2e42677481bd3bdba8cc76e66117bd8445b4'), + ('\x085b68b10d656e8eead9c096a8bd74ceb4cb407b'), + ('\x086291f29c7435c7a86e2292a650fa62732ba13b'), + ('\x0872d92951aa6241681a8c4256ddb4a34aeba8eb'), + ('\x08741bf21c11766e4dfa40fd2e5fe68452320f68'), + ('\x0875c65b52f979fc5e44dd02dc7ad8c89009f7f4'), + ('\x087dff3b29ef4a333093f492126ce3f6c2f84be8'), + ('\x08865dab8e02de4ac124cd4d4ddcecf110fe6de1'), + ('\x0886b105448c8987bd5d5aed4084cc864e55f8a0'), + ('\x0886bd268ea8eac119544af4abcd4cec02a556a5'), + ('\x088df89b0e03165a3b4d897644f301e4f117f8f0'), + ('\x088f2389d9de572df25be3cb4502f3b499919350'), + ('\x089247f33f3840763c9706a2c62bcb19fa4cb431'), + ('\x089318d021be8904715cda4967cffc6f8cb8e5e8'), + ('\x0895f8e1b77688a594e472679b58755a330f34cf'), + ('\x08a208fcc50d7fbb129801c9e5e36944f09c4db3'), + ('\x08a326660a6f717740e9deaf7c8baaf252a17be9'), + ('\x08a5fc3bb39d348351f17188611b9c5840770ceb'), + ('\x08aae9af5b715d657703a5e1c027dcfd86d30191'), + ('\x08acc0a75ecda66a6672a2ba2d19bef8cbb16965'), + ('\x08adb73cb66290522e6d9da8c76a95a263bdf965'), + ('\x08af36d99700b2830ae2ce0e267bc78104692f02'), + ('\x08af6ce02f1e2bd2362a68af34d7b370c859d74a'), + ('\x08b6906f410805303a3247e9bc9bac92647c714f'), + ('\x08bad7f3f55e9541bc6e15884c8dff3169574804'), + ('\x08c1f321f12cc3786fc7fda60e605eed3ec0608c'), + ('\x08c3d5295b1b2d96d837227d316fc9d7fd147697'), + ('\x08c89a66642c14c95f3fdf81bfb73859d6622ceb'), + ('\x08cbeffa5cfd336241203e0fa264d141c74926a1'), + ('\x08cf9bfc5ac649c4c981357c9179dbe5eb1c54f8'), + ('\x08d7ef4cb99357dccd1c7f3b5c2f3f9146cf8c8b'), + ('\x08d82baab9fa3596bd1fbc750dc60e2ba5533a9c'), + ('\x08dd2b3bb41673783f4815aeacd726a9d53f4507'), + ('\x08dd9c52b1e12e9b19f4f789d9a9ef0b7b0dc0d1'), + ('\x08dee46b0de3a6d623a7aa6d0cbbc070adfb26cc'), + ('\x08e83a1e276d4f873b10cdbfdfdf13227196fe03'), + ('\x08fe9a0d0782ca21c562954042a89d0f25fa55f4'), + ('\x08ff0b9a10343837c9811c8d6e94caa6c20eb433'), + ('\x090109efec8047c4ef1580496fcc42d47e1bc846'), + ('\x09014d5656008ebd78ca8f424191e09a484b5b64'), + ('\x09059712d16a3bf869ee19b6121b5476ea53d4d3'), + ('\x090dd378bfd15bb4bd8e061f55253206f72cc06a'), + ('\x0916de181046eb62303bb04030ea72408926badc'), + ('\x091f49b1019fe9c3db36c67c5c9197c94d5f4556'), + ('\x091fcf229feb53072014d3492afddac7d80d9f3c'), + ('\x092a803d84be18adcf173803802520ca64127291'), + ('\x092ad0c39efe87cb1e98eb4090bb0db85af69a95'), + ('\x0939041dbc39b1e92083beea9089af6cba5c87f0'), + ('\x093c22b906f84cd6454eff0a5e0094c1b93b3a4a'), + ('\x093ecc2d83493ba10fc29f7a7410296daabd70ae'), + ('\x0940229bc629b6e44a52ae5d3ee935be895f83f7'), + ('\x09411aeb5ab394307ad90f75fcf60b00bf9fbc8c'), + ('\x09417feba104d5a70eebacc1028035adb80c9c79'), + ('\x09431df764faefbd41e6f3474c3738014f89ddb6'), + ('\x094b468980ec3a71bb66a4791a9ad4c1be2302a2'), + ('\x094da1cfe5a94f84bcee5ccf572e3dea3ab10734'), + ('\x095679d740bdd69804ae2e73dad739311a9b17fd'), + ('\x0961fec84d4d12ce2222d94b4aea087e0d4a4863'), + ('\x09662fd727bc3a8f4c31f643a917a00da30ad516'), + ('\x096736b5c081f118951f73c632bb7764e8e266a7'), + ('\x096949614c3bf77c6b4ba5c173d2ba9e2eacd86e'), + ('\x096a15e424aa909aab43fb8ede78767d7b143b11'), + ('\x096bc3f45908e322f3a3b49c3d0025caab6befca'), + ('\x097185b6ba13c4c22b76d917190ff0647bf1721e'), + ('\x0975f3f9415c63e57f44e62867043e3b1b9416f7'), + ('\x097ce01190efa1a7c7c1250767f12778f3aa1923'), + ('\x09863b7d327901169ff6de6ce58be642549dd5f2'), + ('\x09869c44503ad3cbae3277cc2e0466bf49adb65a'), + ('\x098c8df9af1c2fa263c4a7b001da01497cc2f95d'), + ('\x099264a04765a235ca73d78557050062a9be744f'), + ('\x09958d3215e0dc71b22a7c9da57c509810e66d84'), + ('\x099d3f6726df9bab2d20f7c89d88df2ce35cc662'), + ('\x09a29a9ce6f2258fbae5cefd6fd01ca7a59db15d'), + ('\x09a37a1ffd00720eb4b05cb51c87d7b0c5920117'), + ('\x09b07f14d4049765dff249a748c506a60afcfff9'), + ('\x09b42d27552b11d386be86d86ef59de991ad969a'), + ('\x09b436d8e70e0d3845f71d52477c526d8594ff44'), + ('\x09bc40f9e4b3a8023585e911e500ef5175e094a9'), + ('\x09c12e46cdb587e373b1931146c95fa50d546da2'), + ('\x09c256038834952821e79a3e776a30641c7dc75c'), + ('\x09c27f8d7199156a032c712d323655043820b0bd'), + ('\x09cb01cad56db2525166e6248655859373d0e906'), + ('\x09d3977dc73a99a5e1ed38f5f2c41470864cbb60'), + ('\x09d73984cbcd794ac3e0c65173399f3d747a8ba7'), + ('\x09d9fe1107020cf49cd10f833ca2f91373b370e1'), + ('\x09dd79c6ded30a2c31043ac387f7c96c5db4f805'), + ('\x09e09b30c206619828a4bd2751d848f0d722e050'), + ('\x09e94ebaf9097570972e02dcfcb288e02b336bfb'), + ('\x09eb6fc3d7d6e5838f6fb4eb239216d5ecddae82'), + ('\x09ec4e6db0b14d94699311b66b9c74a22c4e4f50'), + ('\x09ed90daf66b7114a26ca0625db7591001a93190'), + ('\x09edcba6fec1736b1dfef2b415899c59ffce91c6'), + ('\x09f2545a5a5c1598153fc04e40537318827fa114'), + ('\x09f509fdcfde0543cfbc37e4f64c02e11d9b4972'), + ('\x09f678f2c25ffd7022d103e1042b2b66b0a51c35'), + ('\x09f959877f805fdb87c88efbc930da5b34e037c8'), + ('\x09fa411f84f12434ed83f2be3e5e3cca3f19b3a1'), + ('\x09fa4266508b1b218231788241b64c579dfd8d8a'), + ('\x09fad11c8dd3b74fb1d6c3e25261c5541b5b47f7'), + ('\x09fd7375583b86b123ed89fb3dfcd819acb57911'), + ('\x09ff186ceed8bbab8f508e7e9c824427c65105bf'), + ('\x0a01ed88b8ef3540708021ed4d6a47a34f8ae970'), + ('\x0a074c1847f172c59a9943a08de8961c82c4dc9f'), + ('\x0a0a636a5cef9ef840d6805a9b791c95494af076'), + ('\x0a0bad444db19e7b7707be017508620266b3ea52'), + ('\x0a0d2676383268d194635163308c0c7aa1917a49'), + ('\x0a0db5d3c87efd646eda7208e95bd97bcd0235e9'), + ('\x0a11672e8574f47c768ad92fe3f006b72af2c563'), + ('\x0a13d93f518cbbd5b2f915bc7d147ece05bb2bd3'), + ('\x0a14fc5a5ce8bae0c83621e607cb61b0c240d392'), + ('\x0a1b36a8b14850b4fba7b476e76add54012d50f7'), + ('\x0a3444734224337b4a3e72822ae6f9fcd96bce53'), + ('\x0a3593219cd24e5bfffa27d92726230bbde5f2b9'), + ('\x0a3aed50064e45d0aeef6c53407d6dcba432319b'), + ('\x0a3b955a4e46b11779f8ecb5641725f3b11c1e40'), + ('\x0a3cb85a63dda62ca24ebe4b326180df2d298d7d'), + ('\x0a4322a0085010819093356a1bfa215ca65732e4'), + ('\x0a46b66534bfa9e76e0b4e3878f6dc8b8fd2cd2b'), + ('\x0a475ebb0d4ffd35c4996380c6ff5e475c02bcfc'), + ('\x0a495cfe42c3419a09341c20d02dd1c7a4f452c7'), + ('\x0a4bebd8d92f59bd8950c3044f2563cce8a0af91'), + ('\x0a4e2864be29e43715b756174fdba26cec53fcb0'), + ('\x0a4e8359d02348837b36b8e6ca579a2580995162'), + ('\x0a5384d8ef449b7f1fe0e724c03421a1ed70eaec'), + ('\x0a558967ab234867680cfbc7d01993c235edf2c6'), + ('\x0a56c6cfe7ce51ca8bf5d858bab681794b8cec89'), + ('\x0a59e3dd961f366ff4f96f26de8e230f71d22cfc'), + ('\x0a6e0d3cd096f32a57256f1809d8cdc7521eeaa8'), + ('\x0a744f61babe075ed09e9ec27a79fef03afa0b66'), + ('\x0a840642745f3fb59476eada65ccb9664417da39'), + ('\x0a9051ad9d4c9ca1c228996991657c00a175827e'), + ('\x0a92b1f3bc8ae94d982f5f77517167451fc68f50'), + ('\x0a9606c2c54d80b511dfd1f6952dc8efc2677906'), + ('\x0a96d819ccc97fb51c76e200a6268c66a7787405'), + ('\x0a995d3b8c91f81478becac4f2cf1305f5cd238f'), + ('\x0a9da21f2f23e5bfeed573e148d770bf65eef585'), + ('\x0aa1523c40d28aa580d06d22518473343770ffa2'), + ('\x0aa29b4808263526354c8ac83509ad30f59582bd'), + ('\x0aa6352b66c9908ab417843005f4722ad5874b9e'), + ('\x0aa6d7b17e732e6a5b1080fe4e6eeaf0af3c67ca'), + ('\x0aa83f04b4454b48bdf49b4a5649b0ea17372f75'), + ('\x0aa8ac6d99c2fb855a5e18678a04935c933a5b41'), + ('\x0aa8bafe43dba8e2eb54b348fd0ab01e19eb4f2e'), + ('\x0aabd560a3b81ca623e4fc88076f8f5e0536891a'), + ('\x0ab2c2d06a9e13ee4d7f04530a04c2d486dcad29'), + ('\x0abac6df1112c4bdc7958f5f873d0ddcd3093553'), + ('\x0ac0a320a28e49fc21b862d7572876c1201b6106'), + ('\x0ac396b557e57936629dfd0b0e36b01bdecd4504'), + ('\x0ad9cc7d916c002499e4d079993d44311c33804e'), + ('\x0adc1707980011636c0f40c2139d77dc7323fa1a'), + ('\x0ae7d0fe9c372ca958f3251320fc5a88615d45e6'), + ('\x0af662b9fc0796b623f7cfb4108568dd72d75582'), + ('\x0af99167ae31436a8b776f3c16800fda8e36a3fa'), + ('\x0afcdc7420b63b4b838144998dc0888783e15756'), + ('\x0afd23c1934ff7edfdb1a6f2bad1fbbbdc8db3ba'), + ('\x0b0d8611f8d41ddcb8f7a4fca4f4e57689556607'), + ('\x0b1686236c1d10ddc7486b7872d234c62c66e6b6'), + ('\x0b1fa09dc57a8df1280f8ba96fa2153b4393b65e'), + ('\x0b1fc47cbf7bfaef371a1a0d71d9804f3327a6cf'), + ('\x0b23a9eeb05e4ba54c90b54a5b0c8deb15ffc7f4'), + ('\x0b251b6b4f76e63cb87eb98f5230307b396eb340'), + ('\x0b26bd2e8c04605300ab91b5b3e2440b53e987e0'), + ('\x0b282c853bdbbfef9eaef69da6fa97d0db523e2b'), + ('\x0b2a441fcac63bff0815b7fe2293a9cecabbd8e8'), + ('\x0b2d1a1152376b8816b82bedb9628bb4dc41811a'), + ('\x0b32c59cffd62ebd5363e2258fd23588891a8bbf'), + ('\x0b37ffbeac37fa15b3b0f8bf065124e326b2bbf0'), + ('\x0b4d7e4c69e42be3cb1730437e51e8983c880361'), + ('\x0b4eafe5291a173f8cf99bb21ea94dfc5926926c'), + ('\x0b50706ca5ee2d8fa09df2c474d9b8083ca9a166'), + ('\x0b511c58b43704b61478bdc6a4161808e7ac1f6c'), + ('\x0b521944e297574b834639c5ff7ebf0625619a2c'), + ('\x0b542aa86216a17711fcbc5b1e827a4592c45517'), + ('\x0b5439549ee07308cea26459bd03cf2c3e2b4258'), + ('\x0b5817dd212bb2828c709445f87f4233e4fef31a'), + ('\x0b65ae50f0e0bf58a76583c7bbe6528d35d7cfc3'), + ('\x0b68087b000ad3a6c091f36f99968d4354fead78'), + ('\x0b6de8d7a737ae7b5a25da1b612ec1c38745b0eb'), + ('\x0b6eb3527c189077f6968d72449de6864355f6ea'), + ('\x0b722b28ed58844d5085b09d5b979502ab4e74b4'), + ('\x0b755a8690cbf9aa1d5147ab8ca7a7fa5c2d4ca0'), + ('\x0b7759ee40004dd5d05792fdc23c20cb8f5eb824'), + ('\x0b7927c392fe563d15cab9df2615251974b5f3ec'), + ('\x0b800f59942459891ba85e2df3fc805703f1c89a'), + ('\x0b81efb6a4256120581f09b0bd3a2604840329a3'), + ('\x0b868100598ec8725a199fa82af8f990ffecd972'), + ('\x0b86872b90e4424ef6a58512676bf3694d897711'), + ('\x0b877cae2a6c03b70424ca7987ffaf77b5329a45'), + ('\x0b88c91336ff8073f34d21ccd683a01f0e0995da'), + ('\x0b8b26261529161929cfb9748f6c306a9acaa2b5'), + ('\x0b8fd2f9ee5a069cfc35c37ae404dc67abaf3509'), + ('\x0b94865b95b192ace311836dd4f1f84bb175b588'), + ('\x0b960f8a004c4630f2567d00b25a56efba9981e2'), + ('\x0b9790f9c62c57b124ce2849d119ee63ed32b2b5'), + ('\x0b9fe32f9b187558ea40d01af62101defd7682f5'), + ('\x0ba0cb75a2e524d2ac8f00e1a799db6ce06f9dd2'), + ('\x0ba3fe32380c2bd1aaac245d8fbd54e03ac6e44e'), + ('\x0ba67d2b7867fce96981e099fdb436550fbca86e'), + ('\x0baef5b69ee612e8142a4ec63c843ac46f881f45'), + ('\x0bb8aaf5cf33b14817c9ac070b5222271d1f0041'), + ('\x0bb980102bf743ddc0879a4a495f2d6e288d2bfe'), + ('\x0bba2bcee0774bf5036add9616f4cafd1016b528'), + ('\x0bbd7f4ea76a6aed1e26feb3786fc5e0430452a0'), + ('\x0bc055a52d6c31b4cc34ff8c75317098c6c26198'), + ('\x0bc373642e8934f7b3709689cf7410b5dd66dc55'), + ('\x0bc394d7dafd148e1d1dbcb9b0817219487f12e1'), + ('\x0bcf6f82279c12904328e5f0cc843a1bb27aac4f'), + ('\x0bd74180b2a1fcb6baf6ca99065e76401f512785'), + ('\x0bda3e34630abc1fee266b22ac793ae2adfab2e7'), + ('\x0bda55756efe083d9e7da1fe57438cdddbfd536b'), + ('\x0bdf76af68f6ccbe13facdc49cea92d67a168c03'), + ('\x0be12d76f3473bcaa22c6023da2af9ac4615b14e'), + ('\x0beadba7542e20485dc4e851d44135ab2fc1be05'), + ('\x0bf691cb7ab44180176c4e59bdc23df743f03516'), + ('\x0bf91819f86db0af7aa7227bbe3c3dd19710acc5'), + ('\x0bf9820ef9d02712bbcf25afa23019fad4fa5745'), + ('\x0bfb6e322ae1a447999f191e042878ddcfe292b6'), + ('\x0c09c9b390fda0971c7db42f6cb5702e940026c6'), + ('\x0c148678241bcea273282bcccfbc8a362dc32531'), + ('\x0c167b6769f77a5947434b64192dbe789ea4f8d4'), + ('\x0c17982ce8127ccd5d392468ad6bb4ce8f2bb424'), + ('\x0c1e4940a6ce579b9eddd5215e17d7a5948434d0'), + ('\x0c1ef086ac4e4b4619b707459aedb7c033458827'), + ('\x0c208519f30943648501c7477cf6492f2cf2d69e'), + ('\x0c22ae53202ff233a77531a32064cf1800dd0d23'), + ('\x0c250b1ea5b0d1b3a7d6b1c9bc917b5425d9279d'), + ('\x0c25f3d9b7c60a902778239125d38148900662fa'), + ('\x0c292b8e012e7a66548eae65a0270b80b7800017'), + ('\x0c2db68963de76f5b00058de2fa21b6b5ca61f45'), + ('\x0c3108df329cb263713c0cba3241e978cb250cbd'), + ('\x0c34d16e0d517b206e6bee0e83ab6d4f4c96e2b8'), + ('\x0c36d320af93e04274ece724325917299c88a302'), + ('\x0c3e02cb31912d42886497e69ac19a038f350291'), + ('\x0c478df3efd52c28cfe5b554c845b4fcbf422cc6'), + ('\x0c4a51a05eb6cf82bd55c41322e668deb4292c43'), + ('\x0c4d3ac31da485d6920c70c4662653305455f179'), + ('\x0c5077e9ab81d044d910e21c4c85b5c0cd18cf25'), + ('\x0c541c4d88bc239998c89eadc4bb52f47bed40f5'), + ('\x0c5527a363ff841f458064e2583f358a7fda52c6'), + ('\x0c55ac95333b3044fa3f5f834701f9c7123af5d1'), + ('\x0c568ec6f7ecc471d9563a835dd0b5a7bff6ffc4'), + ('\x0c586e69dd3e13aba331cd338b4be7f98a6efa4c'), + ('\x0c6366d9c3825f083975ce2d2d107c1db26629b1'), + ('\x0c67b869ca3510b5c39264cfc3481f7f3ccaf5fc'), + ('\x0c68fabe7fa38d95ca35628ae7a42b7716369628'), + ('\x0c6b31b9a3758095c6ec539287d72f39c95e4d89'), + ('\x0c6d6a7e0fa820c08c3842c8f37be4d131846512'), + ('\x0c6e036df63fd4ec7a2aec93accadea2b6123ca8'), + ('\x0c726f2babf04cac0fa062a4d60676146ef53146'), + ('\x0c79060cebc35af9970b09d175f4f1f44640c74b'), + ('\x0c79bc341385a169069a22a15b74b05ce5ec09b8'), + ('\x0c7a54daffa95c58c05a4e8ce8e1041c4dfb0964'), + ('\x0c7c91cef7325339988ab2de54b2ab0e965c7bf9'), + ('\x0c8c14460ac4753dfa9f5626ced35cad5d384a73'), + ('\x0c8cbdd88c1f9b0a86d61f5da11660118c3a5234'), + ('\x0c955df7dc12df47ab3a08ba8c214620921dbdcc'), + ('\x0c9642b05970436dc2f6e81a072c3a55423d9071'), + ('\x0c97e3a8f94d8b584bd228b24ca77297e0e01114'), + ('\x0c984c7993708fb31da773eedea505a4cf44437b'), + ('\x0c9a4ef3c0b7d4ef46c0ba69df05f559ec332989'), + ('\x0c9b20244e1f4f223bf0d73f1a8f90a108728611'), + ('\x0c9bd7d8340750626cae6d38e0bd07d3f49de3ae'), + ('\x0c9c54a433bfd98925e3ce63e78a61cb8bc54da2'), + ('\x0ca031df3161ae0a6c3d39d1a6354183f4402c64'), + ('\x0ca525905192572c87839cedb4897ac35fcf7edb'), + ('\x0ca64539615cce9d62367917f80ca6faae8455f6'), + ('\x0caeba4066d999e7ab896b4f374dd509d048b08d'), + ('\x0caf270a627ccb9664b211cae3dca98ab9c07710'), + ('\x0cb04e8aea880c314432e79c61c2c8657ef0f3f5'), + ('\x0cb073e0f8480955e570aba362a6f904395a21ff'), + ('\x0cb47087cfd434fde9a2cb183d4c4bd87995ea98'), + ('\x0cb618a335ac153abfa11c9f93c0e108420262e6'), + ('\x0cb8b176041c24d2090dd94225b3033d5328d3c9'), + ('\x0cb9d401851bcf8b53df26c914a4adf2bf454e41'), + ('\x0cbcae1ade6dd8e0b679469ae700eb95df2ed65e'), + ('\x0cbea9b48ef14ccb838c83cdc6f369d863d533a5'), + ('\x0cbff8b4887e96b5a902ff07b2a4a178368975d1'), + ('\x0cce7e7c179e33cc8cdc7563e8c5079d435c6b81'), + ('\x0ccf159be8babcfc86cb039031e4742441de0594'), + ('\x0cd0b34f770e67dd903ed917f7170630fe649fb6'), + ('\x0cd0bbc38fba2e01c40051d6c4ae9a5e71025f74'), + ('\x0cd73900f92ae30e4735dea3135ebfbc8da6bdc3'), + ('\x0cd917f2b0968fb6fe43ff7df13c7b301e6219d7'), + ('\x0cf115f80e7003df058d7708cde7962a0afb83e0'), + ('\x0cf744467e70bd8a58b18d81137af83ac66644b0'), + ('\x0cfbaec29e201e91d7d6732288c62ef119fdc053'), + ('\x0cfe82f7b0c7c9b8745e04d3019dc206dd42b7df'), + ('\x0d01fee49ad49e0d53308df825ee40333244ceec'), + ('\x0d0352c26257a1fc78acb4f17d18e55b78092f2d'), + ('\x0d0786b89b285c0207fc00a49e2d64ad5e826016'), + ('\x0d0cadbb125475936daacc56596d013821257875'), + ('\x0d139261798b751eac5c1c943969c3421ea6cb9e'), + ('\x0d1858b088407eeb7652b63f1aadefee586867cb'), + ('\x0d19dc28e939479d31c19d9b327080a5076960fa'), + ('\x0d26e6ec260698c64856d5b5bfe30a9aa7ed79b6'), + ('\x0d2893500e966ea76c60edc97891eb7a33dbb196'), + ('\x0d298881657c5be04e3b87e80f359e2cc950e7ab'), + ('\x0d2ce9e8d63b64b94571dfb6ab1f30bb0e2b8eb8'), + ('\x0d2cf1930d3ee8f1eeb82551325761c9a7e21bb8'), + ('\x0d326d483cc18e1109a793ec7550e4089d739402'), + ('\x0d33675facf579768b7e00d052cbaea4a15be540'), + ('\x0d35c9dc97ea1676599a7341dc9c21d6481f9a2d'), + ('\x0d3b50915751d01038b982eea6bfa3c25ba68162'), + ('\x0d3eff26ffec7502bc8b55bc1c650b1c763d6ded'), + ('\x0d41986a2e3e1538e15c542cdbb360ead358a258'), + ('\x0d46eff1b275c4fc040358af1b53fc28d97436fd'), + ('\x0d4771db117fe4370abcce2c1295919c7192bc6a'), + ('\x0d488d9f858a110758f32c210ed2d23facbcb243'), + ('\x0d4b3bf4fb977e851a9195f6abc8b729db439c71'), + ('\x0d569d861c878789768b998df737a3ca91fc1d22'), + ('\x0d5a3bd9c773cc94292a74a9ddf60ed5bc4ce39d'), + ('\x0d5d92ea60a22c44ddd326a3f4bbeb7a3dcf4bc6'), + ('\x0d63c0fd325cf98b443a28b06a398e43a1d07a4a'), + ('\x0d6abb40d681faf243f38c7348dec4380dafd52f'), + ('\x0d6ae86ebc57014186389cdb913334d4219a5905'), + ('\x0d6cae18c687a398518645acb13ab614b0f4bf12'), + ('\x0d6f32c30c2369ee85a3c10be56bc58dd4cf6e8b'), + ('\x0d708ba6e0d418ca49cb787071d8b9bc93eab83d'), + ('\x0d7252c933def1961a78e1be9ae0d6cdb2006f8d'), + ('\x0d86eef3212897c73a263126fad99d3ce80f957d'), + ('\x0d8e272ac635296cb512b4400315987487e4f618'), + ('\x0d9400e995489b1ec3e0a0bc8db1206852500668'), + ('\x0d9628c1b42beb900cb2a07bed21e0172d48a39c'), + ('\x0d9694d979f3246a2db8795624913f5046ade5a7'), + ('\x0d9fa54bb788b8e09169503abba2a6bb590f6f74'), + ('\x0dad120f4e1e680d1246fa68563ca1009d3cfbf4'), + ('\x0dad560bc9706dcaa8c1976912ff101b4376632f'), + ('\x0db21ff16662fcd90556cf60069ac9f1ed715a68'), + ('\x0db636661695d96d5617b2de5d2a3534b709a235'), + ('\x0dc2f016ffedd18918f7b4727162bf9d052fb72c'), + ('\x0dc3ec0e3606e753dfca3513e7cb56a88ca60f99'), + ('\x0dcc3a115741825a680b19be26fba7adf9fea9fc'), + ('\x0dcf4389e46440d199f5c33158f7dac113cb3484'), + ('\x0dd27f48feea669d080cd6bb2be0f20f17e9322d'), + ('\x0dd50338b4ee574d8dccb9bc864c04883575fc4c'), + ('\x0dd8837f949125f0d027f176a678b65a22c89bc7'), + ('\x0de01325ee190573cdf37d72ad814db90e9ea656'), + ('\x0de0634033fe67f6b5fc2f613357190843a95cbd'), + ('\x0de50841491ed6e68a5cc4305261500e2ee0e739'), + ('\x0dec65cbd4a2204aa69494f991498530ae2d14b2'), + ('\x0dee95a169de7286a85c0f33592a82dc1d50fd8c'), + ('\x0df3d4e89d4d8efd0147bab0e0919c7e7dfd48bf'), + ('\x0df964218165365f1f814d330ff4e3aa66432897'), + ('\x0dffc5f1cebbb9a4024d310c8326980025d896a8'), + ('\x0e008cbf502dc72c0932fbfc07d50fe36e4d60c0'), + ('\x0e0130ddf1d99badd19c0997ea5055e93cc803cb'), + ('\x0e0585cfdd294c0855b396b9deebafac6ca7bb8c'), + ('\x0e08bdaff2abc25ddf8d92582115c2aca83e1535'), + ('\x0e0ce1c2381099412ba2f369accace1b7e4b7a01'), + ('\x0e0dca405c28b8356ec643ed2d7126f42408b171'), + ('\x0e14c9a695c91c1f66a44ecc6ada8a90bb440849'), + ('\x0e1b3f0e5249dd2ca3bbdd33328692eef667d8fb'), + ('\x0e2669c91cb21d1ae733b6928f495869490d0ee5'), + ('\x0e26d975eb522f74ff40287e2b9048b8acf00864'), + ('\x0e2918d0aa51c84cf887a01e68137e8f239db38c'), + ('\x0e2cc42650941f6938e8a532cc3dae95781e1326'), + ('\x0e2edbff7f1440633094ea01cf0ffbb5c27f45ce'), + ('\x0e3772666650c2305b0be7e9647d90532947feb7'), + ('\x0e386cfad95caed26ea0b19bf5101276730923b9'), + ('\x0e3b50c8c6e9603dcb69e6c2c32a5da7d6a1a984'), + ('\x0e483e30d0b6f65760397ee5ef64b56a2375471e'), + ('\x0e52ff8baf4cd098edb5b88974ff2133b55ca601'), + ('\x0e5c925dd50af4310ab9a440a926e758276fce1f'), + ('\x0e5e84679b6adfd528d7a64dd55e9ce65b816000'), + ('\x0e64d1b2e28bc5d39ea4dfc63f0839484e2017d6'), + ('\x0e6519c8f3e9f58a4dacc26d17faa755138133f9'), + ('\x0e65f6c161f868a995220f9e7e89a0f16273d61b'), + ('\x0e69a2a437d8a1dc77cc66f66fc05c3198bc95c0'), + ('\x0e6a7603f646b128df6e2ae539f21a0aea380efd'), + ('\x0e6daaa3ebea4440127bf547b9b463971793123e'), + ('\x0e87758dcc029b07c53938975bdb52948e3111d0'), + ('\x0e8953bbaae336ed6bd7691558fa0e96e4bfee63'), + ('\x0e8e6cde16b919eb5578ebe336b613d81c6c53a4'), + ('\x0e90baf4fb4c2cff7595fb704de6f7558dc59f8e'), + ('\x0ea231ddd05cd9908a77e86c83524d97aaf4fe6b'), + ('\x0ea4d7da6b7d0a80bd0f21f7c11d6418f0e20d30'), + ('\x0eae765541745ca52ccd9025cf53574c335157c6'), + ('\x0eb88345399c5a98fbbd005fd73fe524970a54ee'), + ('\x0ec2f6365a2b0c0f0afda59676daa9acd1160b38'), + ('\x0eca1aa9de2640870f5f1fd1898432b2378d70b0'), + ('\x0ed1d44754546c5dc6ad2c2629c1d632c3e0b916'), + ('\x0ed983830006a7bb4cba33dee646fef8da55293b'), + ('\x0ee1a14ab2b3759dad445e8ee0eb7834970ab0a9'), + ('\x0eea5ab4f978123964dc598e19245adc0e376c1c'), + ('\x0eef171024573831c8c9044f1d95b6836524ed49'), + ('\x0ef0c29caa6aab3e0d1d372ed1973d736b6001f4'), + ('\x0ef9ca26aeb057343038e5e8a0539ef48518ab3d'), + ('\x0efad8502b1ab17adaa6d93413fa32e81be42dff'), + ('\x0efbaa325ff11c5cf196d65fa80698c99e8867f9'), + ('\x0f05d93e50e769eecb78b96672dd477475635d5f'), + ('\x0f08c2167ce2f634d7c64151d372796e9de27427'), + ('\x0f1165efac279afb560b6e92527e6698a34fd259'), + ('\x0f18aeba72ade2d1cb0580733e1c8b6d32e489b6'), + ('\x0f1e8d1f56c4ac1f7cca4b4ac2b1092290dd9389'), + ('\x0f1f92b82bcbf536ffa83a99ab79670ff05edf85'), + ('\x0f21210116f0382d3a13fea906846ffa99b0af26'), + ('\x0f21d77438a5602f3e0876962e80fa2d42a84228'), + ('\x0f2a0f10722d3fabffe8af5d5eed09866e25909a'), + ('\x0f2b12cee74f42b4cfa8a034bfd170b6f54a6cc2'), + ('\x0f2d0f4538974f8e5a11e80aaee6eedb2745f7e0'), + ('\x0f2d260b356881340cf2eeac1cfade6d90f1f489'), + ('\x0f2d2ac63280cab803fc6df5d6e7d713c3298fe4'), + ('\x0f3a8b1843f9a2d7d6b959302c0882a1930f91ba'), + ('\x0f3e95677c17bb8a030680cc4e1009801a0138c8'), + ('\x0f47d588905c85b7c01f47b8636442f71d04a73d'), + ('\x0f4b7adaf9354791b2364715e41d5288bfd7e4f4'), + ('\x0f4b98a0d39d5e88f711558966c3ab11aed696fe'), + ('\x0f4bc331e2e44e59871426577aa5d46e6de037a3'), + ('\x0f52fc8f20a83c9189208dc3b990e50bbd5603f1'), + ('\x0f54d42d1371db120e33bb2143fa079ffed92d10'), + ('\x0f585b368af9719e95fabcafc6961970fdeb5a15'), + ('\x0f5cdff702bfbfc2766ab265d0db00e1161ee7e0'), + ('\x0f60bfd74908ba852dec6f34b2a63d8344bd5e9e'), + ('\x0f6faff4e123ed4e15a706f8847fc09d17a46744'), + ('\x0f70304c486b0ec831129d8d095dd182c6fc6887'), + ('\x0f76297aae678557d8548c3a0c8545a897248264'), + ('\x0f7c9d3af736f304ea916bc81738b13f2762a696'), + ('\x0f83f5f4b492365350d4251860c17816c5f837c6'), + ('\x0f8a14b74118e2b77c2095763148feed727f23a0'), + ('\x0f8e5cb70fea679bf76c3ca20f48f65c96092a11'), + ('\x0f95da9e47bfd85152bc6cfd940d00e452feed24'), + ('\x0f962e26e7c982049e6c212d1dd026ce6eb1d884'), + ('\x0f981a3f79efc4e097ba0e6f839a21e18d905b78'), + ('\x0fa1f1be7979456d2ef9724c8448f79635dcbe71'), + ('\x0faad8fedbf51e45d9127a91986e67c52ecf2e28'), + ('\x0fae4bff82685fb7c0bb88a7a93d35bf38efc2c6'), + ('\x0fae6deba62ba340ff909b7a09bd802ce7550171'), + ('\x0fb673a8788b12a7b2d7297f337e1b556bfc7deb'), + ('\x0fbe326b8e0e9e4548f22210b7d3a81d88dd62be'), + ('\x0fbea47c41e97e6148f5e5b3e002b0a6ce827170'), + ('\x0fc0270e104f14926a2cce089dbbe4c73135352e'), + ('\x0fc2a1f363eb2beeacdb1f725b084faade74e89a'), + ('\x0fc49cbfdf4b76c7d319112696469e6015f5edd8'), + ('\x0fd1dd3bdbe2219293492dfecfe940dbc1952dfa'), + ('\x0fdbf70b4775acd3eee6df7b71fcbf07ee17f8cc'), + ('\x0fdddd7aa4c61f073f159f35355f6ac28a67dc98'), + ('\x0fea615486bcd311898c77ee020f041d002abc98'), + ('\x0fec3c747aecb3a67420e0d17208c1d869b9ecd7'), + ('\x0fed3275ee03fb97019f15c31c049ef5b0606b8e'), + ('\x0fedd367136b47454e71007786b43cd6f85dc93a'), + ('\x0ff80fdfd9fd95d0f80128640051eab0c89021d8'), + ('\x0ff9baf38fad757e71302577f1772459c4a226d3'), + ('\x0ff9d9902555e0f281f87837a049936b5b083c95'), + ('\x1001b64f90a47b3248d67e176878d8b2c3af78f2'), + ('\x1001d90a55e0468e4ec268f0330da89db04b3eb0'), + ('\x1002f3f6cac79eeff332a5c1b002302b13c5d877'), + ('\x10080655c4dc13bfe713ba47117be762d5160c3b'), + ('\x10131c949b99f75113e8aace9763df2ec536c405'), + ('\x101957ed82bd84348a978a701ab0307d236fa4d3'), + ('\x102e43e61898df0e2bd220389f1d5203ac4c28a8'), + ('\x1038c94d7f11b7b1d373234d29ad0f7079821b90'), + ('\x103eaade413e3313efb328fa2f8cd4862dcf0985'), + ('\x1043d0f210b8fbcc96f9787262fe605b25b59e5e'), + ('\x104b5872640b6e3aa11999d5fdc87084d4ae103b'), + ('\x10506e6e14854c2c03261fe606a1582a4751b7d9'), + ('\x1050f0cab6dec3964f3a0065fc8e171a1bf5d773'), + ('\x105763340ee70d00d45b13945b7fe50d3a127a61'), + ('\x1057691d4b02d700cab6ddbf0c1aebb00e0bad13'), + ('\x1057fc6f216d0a8baf44cb7758b824f42015018b'), + ('\x1060a1e0446a9ad40b2015b51f5f8bd1fd76daaf'), + ('\x106b2bf2273a1e2b84b54efe8e1b2bde918fc4d8'), + ('\x106d13a4bdb99e0a48324be82bbd6543644cb31e'), + ('\x106dffbafb94b98f85dbe3c51e05333be8e720e9'), + ('\x10710a8e1b991ba08be9207ad300dae6a4aa1c99'), + ('\x1079de82aebf4d103ad767261c6f16e81ce5cc85'), + ('\x107c2c265b423bc1251a53019b604615853d267f'), + ('\x107cb4ccf1cdb02a12a699ac3de9d2455aaf95ed'), + ('\x107f22798853aa9241be761707afabda351718a0'), + ('\x10801904dbf33e771aa69a4c43ebc92681fecdbe'), + ('\x108a7d7d9d2b546cab6fddde735e720d4b066dbe'), + ('\x108e8eb54f2b8b93991e52eab9b40ae5a41e2cec'), + ('\x1090f5fc8d194d46aecdc7699a186000f6ccb0a8'), + ('\x10928ad210307c4a73995d7ad09c19333959badb'), + ('\x1096f8d1b74737a715e380e7f7d9d73b40324eac'), + ('\x10abf98f4c4c6d5c4e32f19d5cc2c43c01f2d08d'), + ('\x10ae66cb4f6650f088b5d67f45dce3793b367d92'), + ('\x10b21efdc45f18df6e03baab28742729d95454f8'), + ('\x10c237c1d65c71faf41d81d01d14b1803ffc7b53'), + ('\x10c2be2292c93eeec244c7aa7c47ae8040b2423b'), + ('\x10d2bcd8ca20c25a39f9010fe71734689eb6d3ab'), + ('\x10d9083fa29268f3ee7e52c1eec20aabf05c06c6'), + ('\x10e56fc30c4d163ee7ae0b6f85d2b544591c51d4'), + ('\x10f015c6aa0e38deafbcf04a1b26336b9068e6d4'), + ('\x10f07564f988ddbe131ce396ef3beadaeb2e57e9'), + ('\x10f791d2e23c402b1cc40f63a01c878ff995decf'), + ('\x10f9582449662d05e6ebfc37982f6107f4ea8eab'), + ('\x10fc228e7024698270dfd25ed29e443c615d8b04'), + ('\x10ff42e6c9d06523fbb50da007a0cc7bc4d82325'), + ('\x10ffa390cb64770cad3d4cecb36b8b2029e6de17'), + ('\x1103288ba240becff726436d1c7c1d9cc97ccfeb'), + ('\x11034b1be429b3c109cf95e5e62771d81a7f113b'), + ('\x11035eb147e4ff773e3d0051ba5f2b4de66e28e0'), + ('\x1104ca7a810048fa110931d627ea6bd23960aaaa'), + ('\x1107b0d3ae9d6216f85f327d6ef4e64e8b727016'), + ('\x110a2ad5b33194bd66f1053e9439d15cde461f50'), + ('\x111d31deaf117e072ee07388bbf6eba8460cc01b'), + ('\x111e25809f44159bfb6563615b6475d0ffac1b80'), + ('\x11215b1d29d63a199f585e413285cf3963c1cf19'), + ('\x1122365fdcc6cf43475e292e3f85070fddc2b30f'), + ('\x11223f306ae97f7779a0c3366d0c60d37b024e29'), + ('\x112a22654ded0b3b360c650d5504b8a17bd861ff'), + ('\x112db5888aa555eb29cb35f47dc20a26e8b45bbd'), + ('\x11487240fa3a40170021ac3b47bd11e410f67e44'), + ('\x1149e216cc9a91da634d6e1503bea062ff2be77e'), + ('\x114b784811c3c0581b878b84da319636a5311e8e'), + ('\x114dc29d7962fe71465c8eb86c28adf6521ea48b'), + ('\x114ffa6d0646ee64affd7fad281d576bc35368dd'), + ('\x1151d269fcef8b700a4cf46c9d7db67c11d7661f'), + ('\x115725ded3f29b9d4574c8ecf5e39febe0ff8676'), + ('\x115e6a0aacdb83098d0cd5479195fa31bc78fcb0'), + ('\x1168905463ca6b80f955d5b2c698d46076df4703'), + ('\x117307b91a8657f79d373abf1aea92ddd79b65fe'), + ('\x117ab302ee50e40a07ad4e51a1bd3b460ba0d406'), + ('\x1184988523199ef24fc722b11b0ce91ffc1e8803'), + ('\x11921754a632b33542914272d53e6108038ae576'), + ('\x11983929613a2a72c26130cd2a997d9965097054'), + ('\x1198fc15a904ecfdb6ce0ee5eddc48c3ce1e5f34'), + ('\x119987cd97f7972b5501b79477b0f4a2022d30a4'), + ('\x119c7f7d54aaa2ee525f0c493eed66e9bb30308a'), + ('\x119f6b5f38bb1259ac07e3f38c0c3a94dfd025b9'), + ('\x11a62889009e90058cf3bb615b142b40eb80916a'), + ('\x11b9a7d1eedcfb1bdd5b847ff30ff3a1821b7d07'), + ('\x11bb6a73c48d2bc4d5ad6557d5f508007f54de39'), + ('\x11bf366676a465c6751c6e206d07e7289f729b26'), + ('\x11c2cfad517f8da7526613301eb6c6ebd8444029'), + ('\x11c6e57303215bfb87e96b431575e581b83f09d9'), + ('\x11d50157b0b2ecb2d95763169ff80b616a37a113'), + ('\x11e84a4741807de3a88e846f59b3519b33ee109e'), + ('\x11e8c9f57d1c4441cfdc8fbcaf8ab178744d8fbf'), + ('\x11ea8e7900b37b99574cd0da5dcd9294d8fe3fe9'), + ('\x11eaa1ef4773eccf3d20c4cafa4a98e6cf507ccf'), + ('\x11ed97e0239343a3bbe3c7b77ad83d45287bedd1'), + ('\x11ee1f40b7f4dbf060d406207c4b2054b06c6d6a'), + ('\x11f5dfd1286b826875a62ccc16e9f1cb9b7ce197'), + ('\x11f6848a95549125084e13f81275e8ff581962ed'), + ('\x11fd5824504ac104a8bc586f223dd8681a691595'), + ('\x120676c32e27d727b5bfa5fef3498e4111c1ddfc'), + ('\x120b56feda2c9de4c41e6d8b7f9b02f2737986c8'), + ('\x1210303432b5c5f37664ee07a245fe23253a89b6'), + ('\x121356d630dd21fba82cbd650011f6990b2bc907'), + ('\x121d8b65f9972a5205eb93b599f41675c6f09900'), + ('\x122308b09101a15b6f626316898ea14a037058bf'), + ('\x1224d216ed9a67c0d199094e49c5c6ce6a75b8b5'), + ('\x1225e85e3c283d22b95d89d19cd1bf8be673e475'), + ('\x122a4ec86170a27388e0f5206bf920c5c3a3cbe9'), + ('\x122c002f4404b08e47bace9cc443c54b78e427db'), + ('\x122fb1f5b7d5b8fd186ca2fb1dd5e7758cc5bd9f'), + ('\x12330b3f5c467ca959684992ff9be8fea83eae72'), + ('\x1236535abd56b1f6b7e47a2c0920fb3bee4eab90'), + ('\x123a7a53016f0d01493bf3e30a0cf7f59567bdcd'), + ('\x12426505eafc71819d282c4ececbef4a67872cf4'), + ('\x12464da78aa0074b2eced4cbd4934a737e480db2'), + ('\x124c2ecd0e3a17eaa3454dbbb740fc5213cf5dbf'), + ('\x124c2fa4c1cb852f866fbe6ffc4c8d175a52d677'), + ('\x1252c6d4fb00f1659286c602b0dfbcba402291f3'), + ('\x1252e91ece0d180e2806b3f5f3e903062b6954a9'), + ('\x12554d47d153dfbaa427f92e299257e14549a1e5'), + ('\x1263819307170544075eab86414d3805d934929f'), + ('\x126e55cefcb47f4b5fa395508c13d3159c2ed7ab'), + ('\x127213cd9acbe56f8550b739ddb3377f2e5046b0'), + ('\x1272e9ef4a6eddf2e85d658a8a0a09c7a1dfdd43'), + ('\x127e17010e59128aeaaf40c0fea08b86aebe7f88'), + ('\x12828172bc815001cc5d142d3367d9caea3107ce'), + ('\x12862e40e9d5418ead2bad11c45b6513f21260a2'), + ('\x128c5708454a168b837f09c7ae20b83f190e9dda'), + ('\x128cfad7a1c92dfc06f09a2beca3334471899c3c'), + ('\x128ecbf00e665857ce04d1ebecb3682151c68752'), + ('\x129a4e5ca896263e682eada9f590c2fbafb026f1'), + ('\x129c942b7aca2c2feb997f627c597657b5ffd76e'), + ('\x12a15b0f287b9e1b14634e33333244c6fdb8ed65'), + ('\x12a4ff0a6511f083412bf1407f66340ff4e19bbd'), + ('\x12a7b9fe440242c82f4bcdbdd6b9e5827bf9a952'), + ('\x12b8ddecec6d7411d5da3fb5a351284a4330f7e2'), + ('\x12bc04cd6102d9b34ea5851048c2caa3bd94911b'), + ('\x12bc4d4d5222e1980d3647cd7e76add836154843'), + ('\x12be13ca97df8dfb86575429aef7606a24143928'), + ('\x12beb3dbd898ce5957df31d4a94ab5643e2ce995'), + ('\x12c17e45757a25864eb324d9016e116ff4a98b46'), + ('\x12c837e37605cefccf79458bdbb8a98f31629005'), + ('\x12ce122a8a35671f47b06a5525c1cbf9eb50e0c8'), + ('\x12d386c5cf4878490d348d9b72a313fcd73c9cad'), + ('\x12d452800662e4027991823399e9dbcadeb06b9f'), + ('\x12d9e78d8cf10c19dddb19fdede26b43ec15a70d'), + ('\x12e8404b749e5d30630a51afdbb544af441db14c'), + ('\x12ea97747ca970f4dce17405eb3d1e939a2523ec'), + ('\x12eb7b0b2d5fb264a4cd1fe1adf36d50b4ab0cec'), + ('\x12f0a7b32aa88a513c440e900e163794bfbaa557'), + ('\x12f1be61e428aa6f518a5fb58bdb8a7c08c649f8'), + ('\x13024b7ac7ee3c002ae491cdc0e503fe34055f34'), + ('\x130f91249bac58e5ee658d09aa73e67b38bd59a0'), + ('\x130fb4b0c30c57ae723644aa4794460a50b70d9c'), + ('\x130fe70310a3440f3372bd64c35d95dbaeaaa007'), + ('\x131909836a9a5a1d795b9d7649a5d46ba62f0d35'), + ('\x131cd65d7ddf94b3c6f9de4d5960db467b4cb5c2'), + ('\x131de0fcb5f69991c5480668ec01a2137c4ad03c'), + ('\x1321fdeaf55a357ab030df9363ce36f16d48f3ee'), + ('\x1324669483ae7a207e3d80beee5ffdb315438e46'), + ('\x1329ec63dfa46e284cbeacb5b651852b509ae20c'), + ('\x132f68ac279417e6bddeeb5a476db6c52c052b8c'), + ('\x133309e8cd1bd4f240abd2654f80697e810c6388'), + ('\x133552cd32d39ef5ba0a2ec3cee88a3cfdca54f2'), + ('\x133b4631225f516f2d003b669de8d5a4febd5590'), + ('\x133b9607aae484796975b73f917faccfab586c27'), + ('\x133eabd4724bb7ccf71dc7a612d1030f17b0c420'), + ('\x1344c33083673f462bb7bde28c37ce7ed8f65053'), + ('\x13467ad1e6438691aca7ad5542f357d7e3d7bc87'), + ('\x134883e39fe0d2902a5e90a34aee730e619de3a4'), + ('\x134ae2401133f355919ab4909b199cbd06598889'), + ('\x134d2fb14b33d386b65255dc517c68c3e306b8e6'), + ('\x134d695dcb3b01a0c62881c0faeb8416355a46fa'), + ('\x134e330d7cfb93e6bc4173218ad016e7465112a4'), + ('\x134fcbc3aafefe527afb62629068c8b7495542c3'), + ('\x134fd0ac16ae3cf3c49bb432ca84a5c66a54c589'), + ('\x1358aa60dd638c6474eaf456f7c2435706ac16a2'), + ('\x135b7a92b42fe50173d8a72f4f0cb8f41341a02a'), + ('\x135ed453886386819abbc4eb657b9b0bec89e8bb'), + ('\x1364277e4dbe2de3f1ca41ac2bc1242551faf546'), + ('\x136aca0dd2b06f81b3c7d67db0d5874bad4e00c7'), + ('\x136efcbfcbc1a8ccd5600d5bc730b4014b7ea89a'), + ('\x13750e75daa5b3c7bba26154e012d09aaff9a6ae'), + ('\x1376db0964d336ec1cde01d1968dc34b0a3773a1'), + ('\x137bab905c0755a429ddc83ba605134fa82efa13'), + ('\x137f11e98b0fe7d3af45b35662cf582c7bb4d71b'), + ('\x138383e4b082ebdb0bb055e7fe467786344ed99c'), + ('\x1384da7ef485e15b91a3cdd3eb1ffd9cd97d64f3'), + ('\x138ba36f4f982599aaca22f1f329effcdc17820b'), + ('\x138db13cb3a48700f2ab73b4077f5c951e53a442'), + ('\x13912486d728667a0f8dcda8d9ba54a32fe1036f'), + ('\x139597f9cb07c5d48bed18984ec4747f4b4f3438'), + ('\x139c3a477b574ab5563b13e68e92d1341ee1355f'), + ('\x139f819322f3f2bb694f587804867dc88c717cac'), + ('\x13a05095952d2dd784705f4708d25308710d66ef'), + ('\x13a41b76c625c2a61c3385ae84f8b1a56189ff5f'), + ('\x13a4fd280d7ddab1492c162e9b9a627bcdcc1881'), + ('\x13a581d35e0057917426b0cb339da57fd7bfc37a'), + ('\x13bf55c7de8af9f4fe827f25161e9f26a293badb'), + ('\x13c15ee1cc5f75fa4026b275385f183fe128f903'), + ('\x13c658bb671a2aec764cc57a1ac3f6314f4e85b0'), + ('\x13c9ce7d5e1c3249b84b5bd920c4f3c684c3b4be'), + ('\x13cbab547b698b4018b3dbb2ce852a708a5e7084'), + ('\x13ce0386145e9feed8a2f05e23e92d04df92f331'), + ('\x13d0e96311f118b65f510eeb1bef840106b3c639'), + ('\x13dd324bb6deab353a0beaa4a733adf40eae4755'), + ('\x13e0f49b9b3774e67d637d15cd302070d5fcc62a'), + ('\x13e357d2616607d0a6ed2c47a549effee9f18f63'), + ('\x13eaf4eaa9d7f15efaae67d52205a15818846cb8'), + ('\x13eb0c6064be5aedcc9a692c300902a6b26b4b1d'), + ('\x13f1f8435cba2ca496d659bd3b98145d765e2022'), + ('\x13f34bb06dcd3aa529aa9f6cc8a9d7644c8e99a3'), + ('\x13f8653c064e2ec66882da4128fe692b2edc3e35'), + ('\x13fcf6ce291a43fb62b12c97ec67962b67443bba'), + ('\x1400af9d49f38f560514cedb178611c48ceb5815'), + ('\x140cf7fe52fa06bb2f68c9a382116be523dacb75'), + ('\x140d94e5b1fe2b8ca779b7c43f3e30c6b7510738'), + ('\x1412d69899cdc4019ec60541bb0a8ab3b86f6331'), + ('\x141431749af27ca37d53b4287a348afe96882400'), + ('\x14181720eebd8e0d131f69df3773d1f005630b68'), + ('\x1428f18d6082442792c26543b11facb179abbc16'), + ('\x142d7170770b90eedee22fc2fb14033cc80724a6'), + ('\x14311a7ccbbdcab73afbe0b7808bf6fcbabf4036'), + ('\x1434c19e5482bb2c0c004e306d1121cba76df9a9'), + ('\x14393c5792e35a8d0836aee4da3bd66a0f56513f'), + ('\x14401ca1a5881722032a9741fa91b2c0f79a44a5'), + ('\x1443ef46f9c65154d45e4307a7d2907af3ad1f22'), + ('\x14441d6c83045c5e1fa2a2556d0ab7eee1789edb'), + ('\x1446040b953b87d63bc1de685c481acc08b9c9e6'), + ('\x144da8a4e9d81b6bb0e9b5fc959db8c114881a76'), + ('\x145b73651f9c2d9921594d9553fc97b30163c2b8'), + ('\x145cd5578a16f4b79980c22bc94f000a791c938a'), + ('\x145f368effa7985fe70d222ba9206952eeab08b7'), + ('\x146197bb5745230b944ae69c0501226b710a967c'), + ('\x1462cf60a19fad0b0bc22916486932bfa78f4580'), + ('\x146b794cbb2916fc87fc18a63d4e1bbe2edeaead'), + ('\x146b9c04ca7e6a38b0d6e74f7729a4d95744e940'), + ('\x146caeb521e7a7fcf83559493fdf373d225ba142'), + ('\x146ece89edbbb5c8d89eb48f759525092f6d9c01'), + ('\x147ae800ce3cc1bee3eb28a6a092c033d1495587'), + ('\x147bbf6dd485800c6f1300a17e4502d0f519c49d'), + ('\x147bf2aa258cc8a4b46b7cc0c435a5bc04ef2f0c'), + ('\x14862d0ad1bd16c630c4f157f6970def4f13e3c2'), + ('\x1488ea4bb66ad14ada91789909d4f3b9448e1103'), + ('\x14912f47e292a2e5d6a4b40c25d6f635b442beaa'), + ('\x14993e6b98308cbf0eeb32e22dede0db2bdaf255'), + ('\x14a4bab1ea53d815d10a898164585ef2b9a1d172'), + ('\x14aa47577fe7c2fc6fb7417bc35c5a0548a8f868'), + ('\x14b47d1c391e488185102a0819d86e5a0a8f8e20'), + ('\x14b777a5e16be31bc569fca9e441b7b187ace28f'), + ('\x14bdb6e294a7edc0327f152123eabc86d6674c1f'), + ('\x14c043d601cc55a74640d8c5c82957d2c84f2ad8'), + ('\x14d38b1f026cda904f04748ca3dfe7cd3ae3e464'), + ('\x14d3bdc137dd37bb1b5afba7b1432bbc7c0f0dff'), + ('\x14d6cac29062cb0165d60f780e7e9fe21ab6165e'), + ('\x14e8c4d47cdeaa313f359c60f3522f78058e79f9'), + ('\x14e96ea9d1f9fa39f4cd92621d16e83fa8dbbfae'), + ('\x14f136bdcd7b14b011a1e4ecc0730f9ee3724a1d'), + ('\x14f689b5ed1fd28cca73e841727d3e39c53130e2'), + ('\x14f900527a2e3f618fbc1f28d681c1677fcae2a7'), + ('\x15028d1789d7410533cd9cad5aebadaf587ef192'), + ('\x150a39f47ebc0874aa9f3f58d848cc7a5279b60b'), + ('\x1518712dc0864075266687e64345db1521f1ad25'), + ('\x151ad9e63c1dcaf7af643393c7e0dd86cb1807b6'), + ('\x151f435068506a6f1dba4f0eb9836d44dc6a906d'), + ('\x152d1af1c1afc41ce9d822d3837fadce9ab5c15f'), + ('\x153903a5843bad8392b8647b3a697f90963b8728'), + ('\x153f6e47627b046c34949b0d3646e0eb312fe93a'), + ('\x15437ee87113085a7bdf17f048f44bcf8e031de7'), + ('\x15454c904cf13d3ac6adfe6cb51b5531c5060eaa'), + ('\x1546c874d8159a16e667021680011cf2ed8afbf1'), + ('\x154960f62b8650b0b3c263fccb76791c0a2bc2fb'), + ('\x154ba1f8548d9bf78e900bee652e7db3dafad612'), + ('\x1550016a02a5da7fa84acb93172cc4d6b59879be'), + ('\x15503fca1f10500bb594740ac241edc5ebcd9ed2'), + ('\x155256b46aa2a269a8ad9772c187ad09a8d72f3a'), + ('\x1558adf786f609d7f694406240d3138b72f7691b'), + ('\x155ad7a64240b359add7d6623d830bf2d6485872'), + ('\x155e0fe6cc0536dc2680825256185a8ecea05061'), + ('\x155ff4bd65a9e50e24df2adbafe17eaa279785cd'), + ('\x15618dc666c363f1d0738d41d4075177833888b3'), + ('\x1562be6e70a0ba8127ffb9c0143b0f85d1d1d229'), + ('\x1563210da3c82dea9bd5ed19308786637dedeba9'), + ('\x156836c5b7123593b29ee284dc1f60a8d9d223eb'), + ('\x156954843e3f2a38a8545678e197a78d87724601'), + ('\x156a1af2cc89b37a323730635c2bafc8741c1d9d'), + ('\x156c2d251dd14b90e1c2f75cedb8a314d50e8b1b'), + ('\x156d152528fba9f5e345123d67ef3f2d52728280'), + ('\x156e3d8d289728783190264406f40ab838e06e03'), + ('\x156fe321b91100a2fc3a2b10ddb23e78c607fb4e'), + ('\x157b1d34febd890303a98119987a5128ae0843ee'), + ('\x157f4f0d427b6bc759f3157de3e6901f25e903b9'), + ('\x158333ffee9b8c271a536692d10521525c671b17'), + ('\x158ee092592d6f734557da3537a42204276ab971'), + ('\x1596bf1b5b0512c66a9066768f0d489d5886e0ee'), + ('\x159979f600a06c1fb8171daf147b11b2512482a1'), + ('\x15a284ef93f1bccb6dc281cb8ed4fb0257c4cb62'), + ('\x15a28ef81d8a27c3b00e9236734b70e4332c0476'), + ('\x15a9b546d469ee28457c69136367560e04572568'), + ('\x15b1d3b31f2621b46e1bd60d66b305a4ad63366e'), + ('\x15b4b44fd273a166e2f4b21e21a8c014387addf7'), + ('\x15bd1fd35284b99e71b1206774446e9221339afb'), + ('\x15bedb7f5f8c6651c1d2835f7de9f7a7d51cd0a2'), + ('\x15c26a58474474098a63797b0443d65374b0fd14'), + ('\x15c8509937d43274f8299c4ca5f0d63eca744150'), + ('\x15c9b82dca58db400c62d229f30fa7d36cb343a9'), + ('\x15ca18bca0e08f1a61ff5c8156ba325acab5093d'), + ('\x15caa385ba59b6231a847174209d9e556353359f'), + ('\x15cc4e33ce8122b64978392f6c654c7bc3e90d60'), + ('\x15cf379ba03410610db17764d4b754dfb8d526bf'), + ('\x15d1c0005852019b2060ae91f976305916e942bc'), + ('\x15d3556c94e3ba3f65a2e13c4ee29f2d1a46e794'), + ('\x15d8e19795b921029ee5aae3c05930bcccdfb4a3'), + ('\x15dad654732cd095d2be9a18bf2a702dd17e3181'), + ('\x15dca05b9e4b7ac100c429c3186d5c1fc4559e2c'), + ('\x15df7f7668d45727e89c67a555363e50ee641890'), + ('\x15e14ac4e026668f54b71f0979d6847607d6caf5'), + ('\x15e539417fe7222c1b400304b588b6fd72e80e81'), + ('\x15f91dadd2a0aef277837be35ab7d0a4d3ef4f82'), + ('\x15f9dc19f5ee07a9c4a7a33e82912039f6115050'), + ('\x160806ba413e8284f2f7def4fad71b584f4e55ac'), + ('\x16138d2da6db11f9aa2c70a9fc8cdc8cd9aa09e5'), + ('\x161594044ce7872c9b5316e95e75064da80b7de1'), + ('\x161a160f01907d1b5350e714dbcc90aa88913fa7'), + ('\x16210c95bb819909eeb9df63c9f5837e12d6e4bb'), + ('\x1621528d3fd3c370244f55d4a445fedf6020836d'), + ('\x162fbce929f0b241fe6c3e9c3cab17408b752513'), + ('\x163994fa31a7a7db35a9633c330a8d4a9defea34'), + ('\x163aa55b757ddd6f50bcedd9fe38569f3cda8be5'), + ('\x163ac042e8556993c6e63950e773f6f0354d4390'), + ('\x16404b26614de2689dc3c46692afdcb3d9b3855d'), + ('\x164193a65e0285fde02202eab64246130d007603'), + ('\x16448e2c5afa59aff84caea226614e4d5c4bc4e4'), + ('\x1646621824944ea9da81eaaacf298bdfe18ad205'), + ('\x1646adceb06e5c33faec06c36c526f2630e8169b'), + ('\x164c6758fc397dab0bca9b73a9e3699da0b93fc2'), + ('\x164ff4781c6a44d43e67b2d4ede40d7e0aaa2906'), + ('\x1652061f4acf347172802e88de4ef81673e0fb9d'), + ('\x16523e13ba8d6020aaae380e271589a7f7254bab'), + ('\x16527530603cd4bb75d94e9cb5f51967d1c1a5ad'), + ('\x1663c0a4072a73241fe18d9a3f091a83ddb66ea8'), + ('\x166982733474c3279031f3eb446db45682be2ba8'), + ('\x166aa07ecab11ac46f285abfaca28ad6aa1fe69d'), + ('\x166cd085531dc50f829201c155e0c5fbb42cd8d0'), + ('\x16765d1e0785a58199c78260dbea98d6dda10ea5'), + ('\x167a1984200e804dcb53587a222000e37ecbcd12'), + ('\x167de34bfb5c9838ee18f5e37e2203d623923eb1'), + ('\x167f42d52af0f4a7f9844b1b250d1394413f64b5'), + ('\x1684147a971661f0aec8583e4671ec1e13c4cb86'), + ('\x1685b99783fef34d2fab76d197e717fe1b5a9ada'), + ('\x168e084218692217ff30c93d099ce3d4436eee13'), + ('\x168f9d713f32f9844099af0b126c67e513e89fd0'), + ('\x169b36bced08c2c8a90444021eb8bbff46e16b09'), + ('\x169eb07cff2c17bd14647334da3a1e7eea7ad886'), + ('\x16a376f3f429e9cf017252bba6ab32d10cd91502'), + ('\x16a77e7bedcf308e3e933142614c1ed9532abaca'), + ('\x16a7db47e13095a90a0c6592557a6bf02a5a0d36'), + ('\x16aa0321ae455776fea83f8e7be89acc00d20849'), + ('\x16b89eb1253fa0b21fe7e5d12278f59c988c4682'), + ('\x16b8b79982e9dad3e5d51fea98d584cdbc264957'), + ('\x16b96d1f3a09e0b4a0525427b2ba229e9d980008'), + ('\x16c0c3b79af9f3ab0b0bfa2ca947cbf8efc194c9'), + ('\x16c2ed4046032bc2b15c217d98b0be919b0796dc'), + ('\x16d224176095a493905e778e899a6f2d9544a271'), + ('\x16d42ff429606cd20905d73358f74a610e5cdde2'), + ('\x16d86144e1871ae54db9cea0e6817ccf2a900f37'), + ('\x16e00c42dd80f8a20367e2fe73d6d41ddaa6ab22'), + ('\x16e54f63a57910bd805095662219ac7eeac7a555'), + ('\x16e9f3adfb1d00e70adb3c51ade0961bd036ca44'), + ('\x16f11bea4bdd6d6b32871b111b57596364a61b2a'), + ('\x16f4e4cc0fd629741aaa0a302f8ac70142f54df1'), + ('\x16faeb3e390bbbf06447b107fe56afa0ec2dc794'), + ('\x1705408fec600d1a8f723c9c07ab004cad347df5'), + ('\x170922b6b4b19bd0b6262c7f12366ef1beb474e3'), + ('\x17096017fd75d502c7c53e8cc2bc40908411e69e'), + ('\x170cba0bff2aad9860ecb936ff88262d4c305b60'), + ('\x1712c9e7ce9dc70c937a50b1c0dae0be89143e70'), + ('\x171422df55b95cdd368ba8ddcca7ce44f4ff2492'), + ('\x171984e56318fa8985e0559c2e040f698278f849'), + ('\x171faa5635300ea31c41096b241524330dbb261c'), + ('\x1720c194727035ee0f1decee81edca592cad01f6'), + ('\x1726ba257deddbbb195ad0d095a67473249209c4'), + ('\x17297064d627339c9e9d65c94b5f240ca16a95e9'), + ('\x1738e1d66de531a3ac2fec0cff7bba4fa334875e'), + ('\x17569e764eb6f678e2630489dffe49734e8116e8'), + ('\x1758b10f6f8bca6a5a2d9c9976d45edc36edd135'), + ('\x1759b389915feb598b19d1c1d28e21a4a75d531e'), + ('\x175b73d98d0ca7b7f31a2ff5bc29ba3e234c392c'), + ('\x175f3fac35c61eb7b0f1075eee44a836cde640af'), + ('\x175fe91a52661ebb16f6cc008351db77c306affb'), + ('\x176217dfc436e748371e8c1f4d2410c837f46808'), + ('\x1769b6abd4b6c8b84e9e64ff83da1288d3461517'), + ('\x177c964a4c118b9e7f7097bda4f176b642ddfc68'), + ('\x177e122c6f1739cace6068a0fd938f37050b8871'), + ('\x177fba369b796e1e61db3590b0db722e9ef6e685'), + ('\x178165402e4ef63dacc927a56ffbfc19b23c342e'), + ('\x1784432bee31e11fe616df39fdbc389910195dc4'), + ('\x17895cd24803afe89355f6073088a762ad8810e5'), + ('\x178998420e248642d249222bb96adc7500af8730'), + ('\x1790babb20799954452d45478856741de66c69a0'), + ('\x179d3f7f48d5f620b83e56288a4542088f5d8bc4'), + ('\x17a37c1c4cd9882013bed119e163091a3cb19131'), + ('\x17af2f07e278c7b0f264c23fd5fff0ca2ce5727c'), + ('\x17b01411307bed6afb99868260f67cc3b40c39da'), + ('\x17b01eb8983c4843788f0c436c8f7f7cf8802028'), + ('\x17b0f4d72b0e262a231a0ca567f59e902e7a024f'), + ('\x17b20e630aefe22fcfb483aad2fbef1c3037aad5'), + ('\x17b33816d7ce2ebf0a6fe5e1290363a7c0c4e38b'), + ('\x17b8e1bdbb22915a0f0769aea5770d7d45d75792'), + ('\x17ba821ef4c2dbc477ed6c1fb5abfd805d302f2a'), + ('\x17bfaa5bddcd2cbc8c3622063adeb2738d9e4306'), + ('\x17c149175ec0670b4b7b517eaf3c9898b99d2ff3'), + ('\x17c7d2d858d1957de6b37994c7ecf42dd8f038ea'), + ('\x17cf030fac5a02d7ee943bd6b4d4330442c8c6d3'), + ('\x17d1143fb4167d605b00d2bdd6dc5932bb03ce91'), + ('\x17d2a4ddbc3fe18c65babef1f14670b4fe876876'), + ('\x17d72603207652621195072395d293eebbd3a6df'), + ('\x17e0cd7d22efcfb9c24c2bce63c3a91d5a95d969'), + ('\x17e1dc142d4ce9527c80d00c0790dfaaea38a6fa'), + ('\x17f292cd2f16e28e8f49a382e1b843d4c5cd0736'), + ('\x17f55bcb6126ee3fe324f7352fee5c6a9108bc54'), + ('\x17f64cd1b2a5b42c084d60a2b9c86b769e930ca4'), + ('\x17fc5e9902791031133b3873178529440458c353'), + ('\x18008ded6bdbc751c1315dc42c76ad2d500f58e5'), + ('\x18056aab6cdfb0674bf0294b137987d785df1e65'), + ('\x1805a0b6b7198effbdc85400d5a8e900702bc159'), + ('\x1805cd1782c28707c4153cab2bfb0f4447494ca4'), + ('\x1807bb20c1cc0dfef4d3edc28efcacf3f48ca5d3'), + ('\x180a5d68d3f9979aa58d59ff7c775d7aac095573'), + ('\x180e25079da796ffd776597a9baf027544109331'), + ('\x1813cc54183092fe1e752884085dce8386831740'), + ('\x18150bc55473371c32946bceeae63ff976b4bc83'), + ('\x1815893964e6679560c76286e8169c7ee980f712'), + ('\x181aa90cc1cc6055edb9656c621f47af27266ef9'), + ('\x181cabd3d138041d9b2d6dc83fa397e95021a41b'), + ('\x181fc427e32db2b98cfe6c9f10148790bc1b4b4b'), + ('\x1821bb627fa6bdb9b408d6c1eef11a4dc2a9c721'), + ('\x1828e06f62fe70fd163c70d7ecdb32a6bb7be022'), + ('\x182ee1a57f3f9e2319c8fca5eadfc254d16f9783'), + ('\x183097f19c5f43a505d50a73e4ff0d88706f32f0'), + ('\x1833e3637586324014091d9cb861dd119876681e'), + ('\x183468ceb50d2bd699becac9198f46a660a99029'), + ('\x1835ca18864bb7db0e836bae2695ae0cc45b47e6'), + ('\x1839aa0efca48192b18b1ec1788962751588e2ed'), + ('\x184349ae0aac974194d9ccfa80e3331ebfcfc52f'), + ('\x184ad50855def8b29e1477edf721d361637c246c'), + ('\x184f33f6a2d58f7e493b49a631872fed238038ae'), + ('\x1850892c0c224b9811964e32a35a7e30a15d2fd1'), + ('\x1863a494d197e153dfa76defd872d0aefe2941d9'), + ('\x1863ed88e9a3777e52bba05c754d34b54f0898f0'), + ('\x1864e8e08f8b2f446ff5db3397b3a43df5e4a293'), + ('\x186f612c36d14bd6961146143ff465b27f605599'), + ('\x186ff26da74fb3836f4b02835686012f46283e66'), + ('\x187328921ef6df0cc69ebfe142bfed779e23d8e7'), + ('\x1877b1bebc7d099841c8ba1e448b72044b4c10d6'), + ('\x187af15752f05efd5264acfaeae297a6ac0ac60c'), + ('\x187b3f9969fdb71a1a1bc39fbf2c810f246eaab7'), + ('\x187b6d5b0c683c7978d586af8f0cd0e5102ce678'), + ('\x188217d16967234ce8f679fc77186bb979631c05'), + ('\x18959bf2faea983b9544f5534a83b06b5cf297d4'), + ('\x18982f9ff65a74e00f4ad5a1e708d091acf8dfe8'), + ('\x18987f2db0a2f9784ca5c7688946dfb73fc8071d'), + ('\x189ea0bf79be2fab4b4c453ed1341b5c68383ab3'), + ('\x18a0f7874acc62ea8ebb28ce42d030e0bd5bd2b4'), + ('\x18a1c472898941d82c78e28880517cff53996773'), + ('\x18a555e4411aa84b8ec55f9382a67ee8e9a361ef'), + ('\x18b163fe6fb63af3f1d1aea6220a9d63de156e85'), + ('\x18b354d15a527db959882fc81962fc6e54cc9bb5'), + ('\x18bf3b82b6c3d789880a92b82e6e378cfee2c6c6'), + ('\x18c9b3e9bbedc17261f8818f7b54cb9fe6a1f03b'), + ('\x18d22c2ec7b10c524502e572c9aa630047e390fa'), + ('\x18d54e411ee51895279715b1e190eb6d4d58c8a8'), + ('\x18e31a7aa94d8b7408788de6c27e181f7b15ae6d'), + ('\x18e7af5dbb1aa74f8d001830b294803bf44cd79b'), + ('\x18e8f17cecfafdd0a0bdb247d2f9b1e6ee406cc2'), + ('\x18edbad8f35a77176c9ee4954a4c00c5add28795'), + ('\x18f57c89fe3259477ab47da8e8a06b9f994a433c'), + ('\x18f96273e95fc0a896a83a72d11340c6c7b646b9'), + ('\x18febc1c330df802ff3a80022f0d5e6ac0ced9d8'), + ('\x1900b9cee61d95379fd037b2e5a70d59722cd797'), + ('\x1905c06ba8ea2bb1d417836da7059b5ff650f2fb'), + ('\x1910910220b73046d43a026300c230d8fc517bc9'), + ('\x191b73366a3454eedeb68a6be1dbd460bd36c14e'), + ('\x191d3ec98d0d1996f4f6d80ccdf53ce4f472eecf'), + ('\x1920509cec757e48fd6998d0189c75695680a39a'), + ('\x1924b73de8c5ce10e5d397548c7c827c51f8e5b7'), + ('\x192514f47899ab9e250363d1d58cf1a57f46a048'), + ('\x1927a2343f30230fa3029a75fbb46c03337dcea1'), + ('\x193ab369d6e48d376279b7270aa057ffa70fa10a'), + ('\x193deee7894b8f6322dc7cfdab756e7ee9b09233'), + ('\x193f8e2bb6ea4993d6f969ec694bc209c1a76a8f'), + ('\x19425e74f8e8a34bb474b084b9e8da6568bb026b'), + ('\x19437bb3102743cb425e7cd9e3a132c226b8a6b3'), + ('\x194fa2ebd4cf7c2c6354dbb74063e5e4269e8d3f'), + ('\x195119be40562823af37a7e3041a1dcf6bb3273c'), + ('\x1954a37469fd33cebd61a1e9beb666ee173d1db9'), + ('\x195941c39e47e04f41fb7ef0d6a811c562477b7d'), + ('\x1962125aff1a695c0a383ac347892ed9fc8454e7'), + ('\x1968c0e17a5d463f5b73df8d3a4e26fd46c97815'), + ('\x196c30333c40522ab846d36b6571cf207973e6c9'), + ('\x1976c97a390a1af4f7abd0ae299fb84fcde6655c'), + ('\x197feb775ec7c0ec620b4ae8eb26324ac2a00283'), + ('\x197ff07ca770d1264d82d86f2a59e76d05e10b93'), + ('\x19893fd8672182b6dc2c6ddbb7ce6aca173d51c3'), + ('\x198b2076bf341b779f3ed77d1f8b31b9ab3cd16a'), + ('\x198d3b4339fd0c954f56f704d4c90be03b7eb22e'), + ('\x199066159582072e4ee1e36d7256bd26cf74e5d0'), + ('\x199dc4fe5ddda5b7e9869fee1c69773682ab8826'), + ('\x19a0af4421cc478c06b01669d067b59d96e292ee'), + ('\x19a14ee0e20972987947b97a4c26ac97281c16d7'), + ('\x19a7f0ab1c93e1293579bef36b0666d88746ce11'), + ('\x19ab1dcd0e6fdda4b4d19dcda8233ae323dbf81b'), + ('\x19b2872bfcb4b9f9780f98f9ded5d77c28ffa0ce'), + ('\x19b5f315a8ac3d6f70bc3f0a944cf72c7dceb0d4'), + ('\x19b89c14936e76ea13cf3ffc1aca2320d976d01a'), + ('\x19bdbb2ddb1f5c15456e00ba00ac5e817f27e73a'), + ('\x19c6284f6d0fdaf1fa8d0bb714dd53ab1071c6c2'), + ('\x19c85eafcfe59d1e168c806858c3f063a98aeae4'), + ('\x19c91bfe88fc6ae873d58f823555905405ed8877'), + ('\x19cfe1f6ed87600500d4d116e39cbc5f8ac5e2c8'), + ('\x19d28ec441430d9e93801eab2ff1626ab661c79d'), + ('\x19d2c33927bbbe39bc26dcfe97c70ff01465e24d'), + ('\x19d4310232c6d9aab42a924929bb77c08294c8f6'), + ('\x19db0f2656ef77697f07095b305fe296f6c527d0'), + ('\x19df64e496a246c9b8b5117c7842c31e9ee1ac0d'), + ('\x19e9bfb80f505fac21905e9fd00434c3b9657f3e'), + ('\x19f708add2b52144b52468e3603244a72b319a24'), + ('\x19fa1b11a0ae6294881a63d17a1b9fee4b85f858'), + ('\x19fcbb6f8a3c75d911c8da84b387044e81753205'), + ('\x1a04b7b317b7d90aac73d0f89a5480c95759eac4'), + ('\x1a09b71fd11c6c85c2fa9884dc9bea3a75d4f575'), + ('\x1a0e3f4de6b296d163739833a6626c6e782446d8'), + ('\x1a120fd36bf66751a4e948b9f24c48655b51e73c'), + ('\x1a1ad69d18f57829a30be526a6df3b61b2d36ccc'), + ('\x1a1b6e3572e2882add99302be1a7c456d167bb9c'), + ('\x1a1dd36e017cc82a7ff67a4025e11836b2a24999'), + ('\x1a2821b6b93bb13c73a9397dce413b2dd608e44b'), + ('\x1a2d27dfdb67c13aba8c8b8b93483c2d29e49f0b'), + ('\x1a31bc64e9cdf43f9622ac28050522a10628d5f4'), + ('\x1a38674fc1b798e15d9bcb1f33d36c31b5399304'), + ('\x1a3c0643aac90e248dd2745691d0ebd194c12d61'), + ('\x1a5041e4436cf9d43a8a2a36c392df2377a91b95'), + ('\x1a53c0b2c596666476e05e1cf034fbe640cf7ac4'), + ('\x1a57257a59d49f84b35c9e5b6b884b15cdfca493'), + ('\x1a58e986edfd83c9f9d4955409a80068c74fce29'), + ('\x1a59facbe303e1950973d71e53a78915dfe02774'), + ('\x1a5b9a537e3ff6e54580da1db4d3b25b21b8312f'), + ('\x1a5c5752cf00e60cb4cf62e34bd2a1425e2ef91e'), + ('\x1a5e793976347675b7d99b179d19cab33aaecf67'), + ('\x1a64c1422d5cc7505db796e49fc9d6bc451058ac'), + ('\x1a65a9ea72c7a9908478e4445a06f750cb33dd80'), + ('\x1a66d0f64ece84aed8590e83699bdc2f3a8b0df9'), + ('\x1a702a5408784bd612577a7f913622ca77beddf1'), + ('\x1a7059ded209dd1e82ffa7d7f8a30152b293082b'), + ('\x1a70beb74752c374d65bb1599e0dd002f19a760f'), + ('\x1a741256d9c500810e031af1d47481407c569b08'), + ('\x1a75a4455ecb9e570726a120335d096f55c46554'), + ('\x1a77653761d2fc2cf3fe7a042f803c132fdd86b4'), + ('\x1a77fc6ccd83c5b20e9b0215653a79edf77adb6e'), + ('\x1a7a75968590ffda8b08007c75588a1a644a9dd1'), + ('\x1a7c1939edd6e4d718a4556bc4b8b9860eef3388'), + ('\x1a7c4b70b7c53e344c49c33ef9c3a69cc879d8fe'), + ('\x1a880a30801887f07fec6d8f1b1416acde7b3e68'), + ('\x1a8f821f324c6627a06f8d167a40f13e393e681e'), + ('\x1a8fbbe2d4caacd8f5ea92d0684bd8d78498fc2a'), + ('\x1a90792aacf1eeaf59892ef6e1e54786e662c2a9'), + ('\x1a91dcf2be52e28d3fb6f7adb777cb45054442ab'), + ('\x1a945182185d7b8212f28e7dd9e9f10a9f5ff7dc'), + ('\x1a9877d6b749fba684cd34d3f06ec055119876e6'), + ('\x1a99f9175306c4af6b675a61bc9b181c14ab8146'), + ('\x1a9cd80ca7850b1003a95553d974d0f2535506a2'), + ('\x1a9cf96749cffe692b7aff369bf6469395694bf2'), + ('\x1a9f191001d967b2c279356e42ac0fbe6b6c9432'), + ('\x1a9faf977f749b2141d0db81c8793e1a7b23530c'), + ('\x1aa3c8e7af04d1747d8fff50d36449f2688a0f0b'), + ('\x1aaad5bf6415e727dacdc0be2f25687fc80aa106'), + ('\x1aac92d7b17e5c39e8397c114093c2999c6ed929'), + ('\x1aad17a23b3fe0109a6c2a9be9e9a0905d56b594'), + ('\x1ab03b7f8cf54a99d60589951d7fdaeb62ef113c'), + ('\x1ab5287518ef7527935b4bfa9dc9f5403c5aa265'), + ('\x1ab6dd283a1342f46b32d9fcdf0f8929f788a55e'), + ('\x1ab851223335dccf9272c0b84abfe96c46670710'), + ('\x1abcf1897be2cbc693f5b228732a5852e1a80f8a'), + ('\x1ac278e4dedb9dd364bee0750a24f67d2de10b1b'), + ('\x1ad2e7c43803fbbbfd9b54153e5a88f5b45d8bb5'), + ('\x1adc183178d612fe8f656bf66a755ba476974283'), + ('\x1add86ae73af1cbfec755ba757274a447ebba67d'), + ('\x1ae6950c011ec81e2afc74eab4f30ccc628dac2f'), + ('\x1aee27c186ae7b230d4a53d4352f6a4efab1fb4f'), + ('\x1aee3ad8b025164e32d4592e1be86ad769194282'), + ('\x1aef16986f6a2a207e68899c43bfefce9b41f22c'), + ('\x1af48a3269e2bb390e2b737a67a94ebda23dc7ff'), + ('\x1af523ecd7fdf0100a690cff5f1a0cc927bb11be'), + ('\x1afa3948db47da1e0d4a583e6cc8c362d0f69db5'), + ('\x1b04ab321066e9d516720141ab3170afc6048cc5'), + ('\x1b0896441abc7686ac06f9e6cf9c8d9cfdf71005'), + ('\x1b0edc4b7d42c221a3708add155d4ceb9f77527f'), + ('\x1b14f8525d9d66b72e348fbbce648978b7e6a977'), + ('\x1b19e03050e4373e8138aead7570091c3d2b48f6'), + ('\x1b300b88ade9481c2065ed4648c4320aa03a1e9d'), + ('\x1b3cb19f4b10e59c1c9e0dfd600e870e6a30dc99'), + ('\x1b3ed4d57d03a749cd58b9c2854d49678a630ea6'), + ('\x1b3f5a6c83ce9861e3f5ea589b37a4625f1cef39'), + ('\x1b434ae1ec1467aff40d03d4b263994039480b4b'), + ('\x1b502ba532ad317f6558a37d71f968a532ff50f5'), + ('\x1b52bfca66853c0723b4afe1629033c0bd34b58c'), + ('\x1b5459a8e228a5a5fb64feed4596c351ab68963c'), + ('\x1b64a6405a4431a59f5fe394877ce0ff0851850c'), + ('\x1b66d3383f3427eb682ed990dc28307f779d7bd7'), + ('\x1b72e9d613ecf4662998e4605024adb81b27b4b8'), + ('\x1b7a79a5b30257fdc4a599eb0e9f9c351a01aa42'), + ('\x1b7b9c9f58d97d685351c689a59fd398d0e1de29'), + ('\x1b7dc5eda39633474af82774c790ee6eb47aa21e'), + ('\x1b7e02e81229f3de945e7ad66daad5ceb81dbcc5'), + ('\x1b93da9a159a9deded9f83528a646d89514bf8d5'), + ('\x1b93f88755f1c0145ba50e3443388c97e076e93b'), + ('\x1b9ba548359fc2e15fe2ec7bae5aaa159b746876'), + ('\x1b9df383378d54962b8cd96b26b77d0d07113f5b'), + ('\x1ba5d4c6c8c679207e0291c6c293c7c9b7cab693'), + ('\x1ba9d7daf590f98d8af553c30958c77509fd6774'), + ('\x1bac0deb0a7cca7d3147a6d943ebd438aad61909'), + ('\x1bafcab449aa30d0c7a6387ed50a6fe054b2f321'), + ('\x1bb051aa70882d37f5790b4e7a76a65955fe0cc0'), + ('\x1bb1bbbfd87e394f03665ebb9f6fb6999b23c33a'), + ('\x1bb5d0d4fbb7c433b748f8c371726d4519dde541'), + ('\x1bb8aec444879e25ef4d0ed673f044c31a79344a'), + ('\x1bbc32fb116ac9483ca96dd3c54244947d5eed9d'), + ('\x1bbe956eca9b59de2614d6e3a9c647cc94889161'), + ('\x1bbfd017e44fe8591eedf33688f8a18a8f2ad1a3'), + ('\x1bc187a7884e8a1d19f8845b4f8816440ebb0afd'), + ('\x1bc1885d088237e59592f2d0154f866fa7f98387'), + ('\x1bc3e2a6416a622fb85633c8968bb48d8a26121e'), + ('\x1bc5cb80d8afc0ce67e960e5cb973ec011c7ee81'), + ('\x1bd36ae5e39db75a0b3e079313779796b18be773'), + ('\x1bd3b69258505027f8291864e8c1e386603b8f72'), + ('\x1bd5a787fd85cf1c11d9c87df37f114d54e23462'), + ('\x1bdb2a8fc12488a375b628643b69d1f1db064d34'), + ('\x1bdc3698865aa30640120f6fb76e4c92c6564337'), + ('\x1bdc442dbb1c5f1b4f0718381baddae98b30a9e8'), + ('\x1be05e91263dfc487b8afd876d9625a0fe078f86'), + ('\x1bf4da385783af563e6cfa0cecf887f293327ba8'), + ('\x1bf668668ee9d3858992e50b01e6a27337e44b33'), + ('\x1bf84f6c3aa9a2f95318fe8ace6d3d584d01a34b'), + ('\x1bfcdd1600f8f69b09ac1b246b7d231738244839'), + ('\x1bfd68e12fe53f700b29fbd035bbbe2c0009ae59'), + ('\x1bff987428a9a3b10ff6f0e43de2155b50b5b36b'), + ('\x1c09c40f74600ea6fc7c95e479b66816526cec65'), + ('\x1c0a3e5217f846f136f9bb644eaae63a01c3f44a'), + ('\x1c1d9b83dbc35121103edeffc93dff17cb66e16f'), + ('\x1c1dcd237fbe0d5db7f3c2473b0e2347345d20ed'), + ('\x1c21f3079b26dd181393a65c5162a3da248686fd'), + ('\x1c22363f866dbd748b493e27be9124d380b16ace'), + ('\x1c26a913ffa026edd17202c352c19c80a6d7a8e7'), + ('\x1c339389c68b58dc365e46657ad6bc4dee7f14cc'), + ('\x1c3c4f829c3c6189d4e6cfe443273e77a305f14c'), + ('\x1c3d27a5c180b40d207e4e78b0ca26caf73163cb'), + ('\x1c4c97060ec3a6c6c9b9b606a7b2d1ca6ba7cfef'), + ('\x1c54e8b69b5ce7790504124046f9e9932b04c642'), + ('\x1c571712d8228babea7094332362aa074f8c3a2b'), + ('\x1c5c301d45bed748da59dce252ab4d8b7ab690e0'), + ('\x1c6d2cf22adb5ebe1bff2493922f8316d4fa8878'), + ('\x1c700a3ed254728f3ed390c9bf01d54ec4452c10'), + ('\x1c742b6ae4a91025f957375de468ef3f6396446b'), + ('\x1c74a5e6b4405adc4187f1904c2aaebf8a2b455a'), + ('\x1c7d512a9371d25d4427196db9283f012dc4c0cd'), + ('\x1c872b73cca11a693527cdfb31d59b97506e27c6'), + ('\x1c8acf6c0f2a2ac373a96f9492e3636b9cf84245'), + ('\x1c8fb19a77f34a263cd58c3ebe975abbfb480200'), + ('\x1c913ef2c9cc7172d8120cd91ae39ad4a6a3f5b7'), + ('\x1c95aa8c10591f778830fb522fd211ed4774beaa'), + ('\x1c9696225a8a6270d7a2a9713ad45d0cf9221671'), + ('\x1c9d4f0ca14c1f9d2d81689a772384d65eef620a'), + ('\x1c9e65c7b828b28bf78d194dd48d10e9d96b9043'), + ('\x1caa39115b566f452b3a841cc174f97a11ce8cef'), + ('\x1cabc51090686a61ae8ac2874953c3db5b1cc7f5'), + ('\x1cabe4c61e9eadab775427058aff4a9579a9df88'), + ('\x1cabf6bb5a79171a4acd4a38377ff0f520414102'), + ('\x1cbb784fb82998fca90bb74065d126bbea88dbb7'), + ('\x1cc04513f3257aea7217b38102c96375da9323fa'), + ('\x1cc3da810873d8339c3ea09c62b13388bd659258'), + ('\x1cc4e974d7c993f96cbd6a0530e8af234a7b0b25'), + ('\x1ce1c801cc28db36eb7c4c65b92241f087013b22'), + ('\x1ce74c10b10ed1f3b192a27e3376b3a1a4d9a907'), + ('\x1ce8922a869c9c3f8017e90ee4db696351f4e440'), + ('\x1cec8b501d9efae07256d7dfe1d9bcebadfa6d79'), + ('\x1cf3f60d8d3a85b06ffea52c1ec1825bb7266f0c'), + ('\x1cf5ecbf058fe4a95818583f08c7c4ea3f00c2cd'), + ('\x1cfdb923cfd6374350923a98f0672c76102e4a7f'), + ('\x1d03d198b1eaa005b16d9585b3a061281d68b52f'), + ('\x1d0d49239890bbcd8c8f0bcd2e139e554ab30774'), + ('\x1d0ee1ac84a3c50a58a0cceaa452ebf69e38b58c'), + ('\x1d103af45b93d4a53d5c99844e92c10ac4459e1b'), + ('\x1d103e9c88b0a68acb7277f61cf0ae05d02e3dbd'), + ('\x1d10a4a04c137eb147142779e8d912ea51b3a66f'), + ('\x1d11bb070d7baa7d26bc30e11bb80e30cbe4dc09'), + ('\x1d1cf8b03abb5dab35f6571dc04763dfd2b558d9'), + ('\x1d1e31b2466210cf9f20c45af592935149952c05'), + ('\x1d227dd765976762ad54fff1d86c99aaa644f2f2'), + ('\x1d22be26210998c441b5045333eb64a0d33d9bf6'), + ('\x1d2f17fca11362cd51f00dc4f26017500607fa78'), + ('\x1d30696541917e2dfb44d88b426c93d05c930c97'), + ('\x1d33f4ee065e8f0e9efe66624bc06e3a65045195'), + ('\x1d34e3cd5a5262a94a2631d2f8226dc4a8aea456'), + ('\x1d3d3972a65fceab0a6eb53c4475bed97a8a164d'), + ('\x1d4150ae56c434e60a139e13c9828ee7a0452662'), + ('\x1d4ad5e556854234db0afd4abc92def821571035'), + ('\x1d4cd23c3bf3e10973d1ba637fdfe0722b87ba26'), + ('\x1d4d675dee9f0fb257b6fe1e258240324ff28879'), + ('\x1d4e6c943860653230830d6df32f4d469abc5495'), + ('\x1d54c953b1069c5430294b4f9114dddb8675ed7d'), + ('\x1d561ff74556eed211a477d9981f14500b2136a1'), + ('\x1d64e086f83e7828d7da103e19ad979aca6cce57'), + ('\x1d6e1edbd9221c57f8dc368d6cc331a903b6710c'), + ('\x1d72232fd03c044447a44e46e5bb4a0faecc8f90'), + ('\x1d72f7e8324bcbed875b8ed24d833fb45264e78c'), + ('\x1d735608147b4158b3edc7f4924f3dadba66cb01'), + ('\x1d7568f1bb8d4307c0b6a2403459740d9129276f'), + ('\x1d79fb9cd1d6abc8d03707c4894724f92480eb88'), + ('\x1d7eb6fa5653114a6041a88f666b761d2b7f22da'), + ('\x1d882537302f563fe8667def5a618901a33ee742'), + ('\x1d8e01e23ef555fb7fab350a612a463666ef78be'), + ('\x1d93c9d97a77d061e5c92c80a12ff5ba98dd63b7'), + ('\x1d943033d9f14c3c4a39d4047c4b13f7044cc587'), + ('\x1d9aac98e4f0f57547dd2a3284eccfdd1fe42396'), + ('\x1d9ea18fbd1756db8629cb130e2c61ec0a039db4'), + ('\x1da119475089e62e25be2e7fa135a46c1cf4c96e'), + ('\x1da2025bcc9a1b09a6947f6c1294c607d366ee9c'), + ('\x1dac1c9cb6eb1bb2c52434be99fe0aa4e1067662'), + ('\x1dae9b496d87acd2da14abfa697c6a41284e1f68'), + ('\x1daea767c7c479d1bec64b4e11738298942dfed4'), + ('\x1daeb4b2589b9d3fdd51da4d889eba4d6c03c0d1'), + ('\x1db1a3acf0e7ac91d10d3470ecb1e3dea35b332f'), + ('\x1db3deccfcc9f6835896e6dc29448a563f8730a0'), + ('\x1db476ef6012539985801cf13f13e8ccc0c070ea'), + ('\x1db6f12dced94f5449a97d152ca802904583a70f'), + ('\x1dbacff8c672b47adbe6d2f9ebfea00d8db7916e'), + ('\x1dbb1a36b4505454ad2628be3dd33c94ab1e9384'), + ('\x1dc0bb1cfcecaefece8f1e76c4cb1f49492c012b'), + ('\x1dc12f6047eb6934833ec06f6e108254505bbb2c'), + ('\x1dc80399436227f65335fab7b05eab1698415cb2'), + ('\x1dc9414d9c4c6b0d5c93db1e4a476a3036981114'), + ('\x1dcc69b865c02422d5f70797b9cd56d233d9f801'), + ('\x1dd92ab3d35e90eed21ea80a023ea1d1984c65e6'), + ('\x1de202f8144b22b7c03315e3ff58d4b3696711b0'), + ('\x1de2c651bdd45a47927101ad576c05a0b380db1b'), + ('\x1de79c3bf86a0e4a162d0ba6a71edbdf03e1da4d'), + ('\x1de7d1dcefb44068ac631ea08b16bca22b3e42af'), + ('\x1df1035bafef318e587dd9458423801e559055cc'), + ('\x1dfd643187ae2435efc15374d230a32d7f2f7d26'), + ('\x1dff0733ec5139eb73ff902d15d6f26dac5a98d2'), + ('\x1e041fbb92c02997da16971e192493f1a87bc18c'), + ('\x1e042b99ff5e5cd7b562d697dae3fb2f8d8d82e8'), + ('\x1e0a189ca4cf212cecabb84a52d124f195669d53'), + ('\x1e0f895c4e7ddd34dfaa82330d9bd1473deaf11a'), + ('\x1e12223ea0c9a5473760c3ec10f30db95b21e298'), + ('\x1e1515019cd6a7ffad2e9bac43e1a2a2e08616a1'), + ('\x1e167b12f379537df0caedde4b4c304eabd1c55d'), + ('\x1e173e4e1cc6b01e291cd50a2fce420ca8ef2d53'), + ('\x1e1bc10122ed6850f88bb7ea487f79ad76133d51'), + ('\x1e28e500c3a90e1281ee132ad8328cd08bf05e99'), + ('\x1e30a204acf6c949726c558404343e00f27b3c87'), + ('\x1e313c039a8f95875a6e83b8b8dba07e727d7f00'), + ('\x1e35339dba926362c9b3a336cbc4468dc805570c'), + ('\x1e398d3276e09765b7fd2be10f46d4c5d2628ce8'), + ('\x1e3a156af2e501623f460e8f5cce7933c17d546c'), + ('\x1e456bf0fe958866a73adb98f1f918048f47b457'), + ('\x1e47f1020836528d02f1880f07dc9aac18609976'), + ('\x1e4eadcbedf58b42ec68b21ec9f3123442db660f'), + ('\x1e60491e4dd1174bd8ac0cb631a4319c4d56cec3'), + ('\x1e6ca625c5530c980367797d1383e56081a8acf2'), + ('\x1e6efc577b559b2e7374d81826e562715f518af7'), + ('\x1e72f27d096decca89e504e85adf0e8f8e6bacbb'), + ('\x1e7412bfad25f089ae34cde600d68f45d8323321'), + ('\x1e7fc026c2a3096cbd4b816d25b80ad62add2a9b'), + ('\x1e8749f9c31264725f03a1b258471d63478163fa'), + ('\x1e8bbdd36002a79b356bc8912001722bbc93ed32'), + ('\x1e936faba2b0cf5fc3148886be32795371b6cb5f'), + ('\x1e97a5433a775d4a2dc0e5375826dc4f8b635ff4'), + ('\x1ea39505466e6c190d11afb8fd347aa1afa6ac17'), + ('\x1ea6df282eefe56d5ee3b06b9d747a311f4fe149'), + ('\x1eaf9447d5a1996a88190cc8b7391a15f59a2969'), + ('\x1eafc0f9555d9e9869a9a1eac5c2cf5d2c245d20'), + ('\x1eb97305a3bf9a86ec3ea6dc7fe8d4189f0a7925'), + ('\x1eb9ec59d25d2c44d202180f61392ee11ad0bbe6'), + ('\x1eba7d7e0fdfaa58ce1f36c48d45221dd5c85525'), + ('\x1ec218f4157c9bee3033e66173fe093f5f823cc8'), + ('\x1ec44165ad9be9423344c5dc20aa4a89dec039d9'), + ('\x1ec7897a4b8e308fad79bb30e0cd31b1753fd91f'), + ('\x1ec90cadcb7fa03ab6bbe4c1546be580a1765663'), + ('\x1eceb522c626ceb087d13379d6dfccac3f287008'), + ('\x1ed6da0843ea22699f91a40a4df7393e738b250c'), + ('\x1ed8e58263a0afb1f4dcbefb0720e215d9d58d2d'), + ('\x1eda164f78e84d4a83cc0fd7a986c074e80b5970'), + ('\x1edc5669d947d4fe8d1d7f1ca235282e9dd25e55'), + ('\x1edf9f0b81d423cd2358e08247c31d5e8f0e1e12'), + ('\x1ee45ebd555482e5e23bdeaccf99884f6f2e8f0d'), + ('\x1ee76ff6be7932e3cb30fbde52afd1478df9045b'), + ('\x1ef352adfbf8e2914bd4fc1c451d9aaf8be21ad6'), + ('\x1efa41b39180b0051779cff178a4f73be7a86082'), + ('\x1f088c86d24f7a71fd75a98f56efdc502d2897ad'), + ('\x1f0b70c50144197c8ceb3b913cf67c507e7c3040'), + ('\x1f0b83086ee89e0c7a3f98c08b13431397da5c29'), + ('\x1f1bfd871ae00fa2ed0d82f9d869db87d8e0a6ce'), + ('\x1f22936c4ea84899f9b3f50c39912d6933d1d1b9'), + ('\x1f244fb8cd17aa67ef235478cb5e8d50e2553900'), + ('\x1f32bb8e1cbb9aa810209e48d655be7a6db53048'), + ('\x1f362254211dc50e123baeb38b4205e90c3b7926'), + ('\x1f3d8ab3bde02696a802130b5ec4bff319b248db'), + ('\x1f42364d500a0604af54d454743f823014b8163b'), + ('\x1f458ae17d5fc5f2e28b097b0391352c7cdd06af'), + ('\x1f4afb4c33a9dee88dbdb093e1ecf2e750c26121'), + ('\x1f4d0ab74e2980ad1a17177226c7f0cf189a601d'), + ('\x1f4eb75f28fdbe217485f77cd76e093daf5af45e'), + ('\x1f51e5f79f7d405ad5f41e10f1844fea9b597357'), + ('\x1f5a4e5e579f19f2d3a997af0f79dca874064c33'), + ('\x1f5cc256140852c8f8566ae30bdb22a919787fab'), + ('\x1f5dd3898db5106fdd2f60ea27c120ae56199564'), + ('\x1f6265874544b5c9656a3ad93c84b648260d1dad'), + ('\x1f6f2ce423a37ba7b9e2e2bb4a2cba54b4c39729'), + ('\x1f7072419494ca2bbee531cb0865f63d164cce15'), + ('\x1f76d50fc6a41eb0fd484e4a1dc7cab45681e461'), + ('\x1f7891722a3f72b77ba2bb14556368214ef5d736'), + ('\x1f7a3150c80430ebd272f4b7e18052847096a35a'), + ('\x1f84cc35d40d902894889fcbed3d1654b3e05e9a'), + ('\x1f9560e69167c4cce1f4a6e26ec442ef6ffe2d9c'), + ('\x1f96b81aa685a21af14498a193007f0025ce970e'), + ('\x1f981c9f387c39b78f8645a276e9c13e66657046'), + ('\x1f983af466a74d6f24cff1ba82d641bd7c76ca34'), + ('\x1f995a011bcf938449d0311466172c26d5255f06'), + ('\x1f9a277d1cff26559c3285456d2a8a8243c6ba30'), + ('\x1fa229904bc6bf5aef95cf3345d6b4df3e361c0e'), + ('\x1fa2b9f199d0d3cc61bdcb80842c734fbc126db7'), + ('\x1fa5aa6002bfe155eae05a82602eb07a51ecfd98'), + ('\x1fa71f8c5540f43ac280925618f68a553c17767f'), + ('\x1fa83430de89e61ad26b71118ee536ac631c3b80'), + ('\x1faa304b0dae456a1bf1b7e249b7ba797cf32387'), + ('\x1fab73a361c37e475c8a2c947d57c7fd614f53b1'), + ('\x1fadb06c573595fd3580fffd7f2464df3f99819e'), + ('\x1fb1a3901fa52d4a166de151600f7bd4a40ea178'), + ('\x1fb2c74081fc527b211472ddc0efdd757a9a1aa6'), + ('\x1fbf6d415dd926c44e63a42d0e2e549c5d13cde4'), + ('\x1fc1a4799e79c2b7b0d815554e1ca2f1406d2fd9'), + ('\x1fc3a3a98a57a2e60ea377d2d19048794cab1c81'), + ('\x1fc4970ff97be137bef093f6a8fa954bc9968da9'), + ('\x1fc4eaa1c9fdace5cec882798e2858de380acd70'), + ('\x1fc4f19086ab259d3ed9ea696e1bd8aa1b403a80'), + ('\x1fc53f2180e1cbbfd370335a00853694819c8b55'), + ('\x1fc726f310719b5793d32f834a039845a26e0e35'), + ('\x1fcb2fbce126f172c710f91f8b81337dd0e62de2'), + ('\x1fd077e8d9219cb67c170a1ddba622931f6b4a63'), + ('\x1fd1967821ca4433e5a69465f801127a38496286'), + ('\x1fd3c47e5477442ade71bd031a26920dbdf22f38'), + ('\x1fd48979233168a4256bdfae5ce982a3f5071554'), + ('\x1fdb5ac438b50f6bcad057e9195f56fc88c0eb9e'), + ('\x1fdbb57657d6433f74fa185c52aa43b03b9c0a05'), + ('\x1fdf13822cbf5beecac56370a8a4f754e6254cfc'), + ('\x1fe11d88fc20ab709ef3c1bfab320859dd794d7b'), + ('\x1fe221bba9e77cb6d54f77bc7faf42e59b2322c9'), + ('\x1fe3ce3fb97421bd078781d95461b1ff18e86096'), + ('\x1fef80a6584a7f11ed644adcd6490a1003971b05'), + ('\x1ff8ef37b3d0fc16741b9c6297e033f6ef5b8cc5'), + ('\x1ffd0af59ce543f5184a8be20ef038de1d72040b'), + ('\x2002020cb57408db96de28517ecd67e7dcad2fd7'), + ('\x201017ac0e87f5c7d39240ae56dd8e1944c804bc'), + ('\x2011d744991a4084f9b55e42a1de306171e95a47'), + ('\x201280614e85d3fb80e0a0cb8ff1b7e54267c120'), + ('\x2012fb0d96a3c3e43e3864b20ed3938e58f4d548'), + ('\x2015398697f6f4c90b17a4244116cc6ce0b00e3b'), + ('\x201cd645d954ba32d6f7c28a994b6546c4be84c2'), + ('\x201ed6b94a8293bdf5d46dc647dcfb5399a6cde6'), + ('\x20200badd8599df6526c5389a8e9f3d422fc859b'), + ('\x2020c74f2bb3aee9ceaf4b3059677ee8b24dc02d'), + ('\x2030de44348337a6c05b96e7cb8de2448106f963'), + ('\x20378999f58feb589e070db315050e2bce52c03a'), + ('\x203a4e6eefce31c01331ef90bade68a74b5e2585'), + ('\x203b71eb84cfd74947df1ea61f30b738b1a15a4b'), + ('\x203d550da604bbe625b1ff51832ffe6d36b65f24'), + ('\x204498540f1f79e9a0cc60d0aaa93a245dadb0e2'), + ('\x20467ae08bc2988e4de6000ea0ed48990f68a952'), + ('\x204911ba710a1cc69f5509d055b306c20fa1c8d5'), + ('\x204a009540a0a6211482564c069f7f77a3538ae6'), + ('\x204ce3a9ed2c6c5726ee20ef5c1c6967f83ec3e6'), + ('\x20605aefbb478b10e7392047d609b468dca7401e'), + ('\x2062e98e6fbf71b59e3aacc9e537c3ecc56c14bb'), + ('\x2066ed2ddfa8aae2a989afb2ad1ec2a0bab01080'), + ('\x2071f2075db11490517eaa8a9cffb20d3d353dda'), + ('\x2074f590daa92ac7d146d74ebf34e0622d2e1a90'), + ('\x2078776b24b67a146d427f4616e192668576cf6d'), + ('\x20862920144318a2fdc026dbc2a9025a2d71ca9d'), + ('\x208ae91c9cce554fbff036871a5f58e7afc752db'), + ('\x209252a61359f8736e6bccfce92f291a1f282096'), + ('\x2099daf24516e1587b20fccec806c1ec531c45d7'), + ('\x209b8a2295b1e5409392d30b4b7db5274e25c95b'), + ('\x209e2cabf4aa16503d8fd3b85f8c82de4d86feac'), + ('\x20a562a5076cb5a9314740a7674d25df7ab963ef'), + ('\x20ada4f39f5ece0043f809d5654e1185f7c60bf0'), + ('\x20ae0b278722974f45aaf91f2b1003cc821d06a0'), + ('\x20af5e48d37060954c48fab71c12f9fdaa26d581'), + ('\x20afafaa20cf6e136f467051b89ecdc3b279a57e'), + ('\x20b998abcb214eea14bde34cb9c8d14b2c924a5f'), + ('\x20bf76bed51ccd24d96d534258b11292c86b5718'), + ('\x20c4cede7313e043247af331a81995370a5a2860'), + ('\x20c9f9ad644bfeb48b6e49c8df93d19428b2a7f0'), + ('\x20cbaf185d9b476923044a201064eef1c5eb0e61'), + ('\x20cc63262b8b321044f7abbb69e5f38180fcda48'), + ('\x20ce4adec8967fd6e7a384f98d8b0c9ec931e499'), + ('\x20df1507b44bab94324cf4d7ed10bf4ef95ffd09'), + ('\x20e1b4b93978073f776868fdb9f8f3b8e0d7a43b'), + ('\x20e2ec8109903dc067d83c8ed6b95231e20391f5'), + ('\x20e396009bbf3276455d393f5e1101f09b628c90'), + ('\x20e4473a65a2b2b6ca3d437f54137cf7125da947'), + ('\x20e7227342f48c2e8c0a1c61e936aa417fd644c9'), + ('\x20e80c6609f1bb7006c59cb24cb382e63e722234'), + ('\x20ea57628b18ed1fae341aabf232d8cbdc53cc85'), + ('\x20edbfa80682534813a2b3c762ad480f7cf313bd'), + ('\x20f1fd2c1fda621be14aaf7e9a96a0fd1f3eee2b'), + ('\x20f278a8efb84f53076703af7fcb6b29770cb7f0'), + ('\x20f53ef38776ae99e1d0bc075ba8f88280038a90'), + ('\x20fb96dc3e701d33b375c1c7001c048e901f3325'), + ('\x21075bb1697d091b8f491b1fd52eeb6de627d402'), + ('\x2109d89ab2d369678506b7df2b52ea000553711d'), + ('\x210e05ab838c2283b5b3018e545784f5939d4795'), + ('\x2118b21f6abb675729b868198414cdb365b79e2b'), + ('\x2118d358a27195b8bcf8d30f18aee88c4c0a9794'), + ('\x211a5792c560748a499904752d49dbc14c7bb2c1'), + ('\x211c687488c74a36241c8c8bfe8b9998c2f4ade4'), + ('\x211dd41138ad0b9b2a0e8aef47f2f4a05e1807f6'), + ('\x212c06e08ed860594a6560a882409b883a342f64'), + ('\x2132fbebcf0809e2bfa2f27fe10538b335289fff'), + ('\x213d2fde85813b053d5353a7fe0384651c68e7e3'), + ('\x213d8dd464c1c915241e783addebfd7f61ac9ddd'), + ('\x21477dbb8bf162b3be710228b7c8ecb038452b64'), + ('\x214cfe83d6716e926041a0181ed02343c5597431'), + ('\x214ec2705997ffb5849023001a0e36dbb7e66292'), + ('\x214fed29f0be9483a6b0b39004133d8b3e591664'), + ('\x21531bd38e8e52a1ac5025bcd48ba4918066ed69'), + ('\x2159bb0216683db572fb75ec0fd9acf593b5a6bb'), + ('\x215bdd5a3927bfd78d8c270396632a501af7f8c5'), + ('\x215e3a785ece41a4e3b922526dc060f523e69783'), + ('\x2162134790cf643aa70cd3e7c173fc960f936e81'), + ('\x2163a9c186ecac73f4dc52dfdd7dd7b1d18f6988'), + ('\x216cb46894b90cf24987129fec50a6cba836ab13'), + ('\x216dbb6d04d7305c7941d03aea6bc16ed9fe6b4a'), + ('\x2170a1cf01f0c8306ab88db61fb631ebbaec0099'), + ('\x2175b204756ded6de28eacf1b3818345810b04f4'), + ('\x2176a0eb202be991b0547df91adade2902584430'), + ('\x217b15e9f3230688a96062d89c4d423ec7225a79'), + ('\x217d2831ebeb506b3d416c65f24cd6a3272e5d76'), + ('\x218013118829ccc1e570dc885a29dbf9ddfac5e2'), + ('\x21834fc8b5cb06ea42a5b9f4b0515c4d8cf69dd2'), + ('\x218381f488e3ef72ce4489b85a8f406cbbe6651e'), + ('\x2187efe3dc74d4f58063e2311654bc9f2f745757'), + ('\x2188b7f807b422bdfa05451d0024a57d6a336337'), + ('\x218cb9ae539f3f1602570b6d0bb7a0fc4fd07272'), + ('\x218ea61b6211cc2e21fc2d9a506fe70b79d50339'), + ('\x219063a578a486b7c00262057efcbc44ebab0eeb'), + ('\x219af636a8e30f7ee0cf1dbc7c11d01ce6c3a0ed'), + ('\x21a0c82e2082ddcc0bde6737d0413a97923af588'), + ('\x21a3db50c91ac0fd3096233b5af00e1d095cb7cf'), + ('\x21a711fec327905b8a4f647ba0c28904969b6636'), + ('\x21a82061ebf2ff5c4c86edb99accc0d5f36183dc'), + ('\x21c2cfc061151327340849a03a761b4084165f3b'), + ('\x21c834865e8d6bdd304b4285482a85e5765ec272'), + ('\x21cd596bf9a48507e5c94271284c5e577c77b355'), + ('\x21cd7c4750cf765ec55b11b37727528a9c60a2cb'), + ('\x21cf999eb277f901ce0ac28ea11d4495b0ba51dd'), + ('\x21fc2407e32b2fa64625a87718285cafdc742260'), + ('\x21fd1f02f3c9f81ff2f1bf26a6b22bf2b5daa8e9'), + ('\x2200cc9cfc6ba4f3803f271b93c203145cc0554d'), + ('\x2201c2f593e3728f40494f3064e29ddbab371e9f'), + ('\x22075508cf56aa49bd88a90fc3cc3e466c42fe39'), + ('\x220d2d74c1f173b94540953f3b094242eb0dbcce'), + ('\x220f166c9642c7c173c3f677af4cf58fc07ef49a'), + ('\x220fe629a1d8cbaa45d82ac578cfc85de356b746'), + ('\x22156a5e507019ab7df5095654f5160fbba08c00'), + ('\x2221560adfda40e18ba1bf119ba416aa4c7c966d'), + ('\x22261a1b33c1091cd8b7e7ccc6bc423024216e3e'), + ('\x222a372937f0ec4f93d045c7c76a4709c67c2989'), + ('\x22315da62cde74a5a2a380f5423bc6118e5b8b3a'), + ('\x223e7205a7b7e18f3611ce52c03a3055ed31f5bb'), + ('\x224328d5583fc6c4dfe1e9fc82ccde500fa7ef6d'), + ('\x22440db339f103ca8f78af1776fd412fd0c88291'), + ('\x22443139013ab40db49fd63c293bd28363c3548b'), + ('\x224d94cfb3948df49814a412908f7a24a05edf33'), + ('\x224db039f88e46de60f578da0b411ac1118ae2f3'), + ('\x224ec756d7aa5d39c51bf8fc3014d3bbb259d9d4'), + ('\x2250858ccc6525c17d5e11d26b74f30566051b67'), + ('\x2252d538d6884c9a04a3b1b143c50a10c6bf7aff'), + ('\x22560ce0967c9d13d15561e69db2e84304e7fc3c'), + ('\x225822b073313f4d55eba747bac06f0f73265500'), + ('\x225902eb70f359615103f3e69fe97e0a47fb2c2d'), + ('\x225d5608cb55db6726c3cf28af04735ee11000ce'), + ('\x22679291319df4af97c8c8beeae0b7392962dcad'), + ('\x226abfeae46fdc2e3fba316eb3c45192efe14f4f'), + ('\x2270023c207cfb621cc12ac010ed41fb25c94f14'), + ('\x2277c1f20c8a4539b29cf543482e1b750da18620'), + ('\x2281299c25c262df0e40785671a6190c2d594cd0'), + ('\x228217b3f6fba2e7703cff0254f1daf505a628dc'), + ('\x228970a959639a2ce62a6f8777d93e10c540769e'), + ('\x228b8878aa8061d2e3838aebd2e6383739e96270'), + ('\x2294837b04c4536f4b51be746b3a829bea17c619'), + ('\x22959c33c95f45a9ad4e5ca753164b615edae619'), + ('\x229d65139e9cbe58f2ea57c8b401dd44fa2f006b'), + ('\x22a0e8b058c1250091ef311cbae97521f32864d5'), + ('\x22a55f68fd8c27cad31741a018372a06ca51ad98'), + ('\x22aca305f35d04766162004dba9a64fedec5e48e'), + ('\x22ae488e2eab69d74830e2e4413cc97d119ddd58'), + ('\x22ae9ab2235c915b9f6de65a4e135346fc129e7c'), + ('\x22b64e5896ba5aa842a9997af61118963f4703ed'), + ('\x22b73d4a02306efd9f5e2190376f836a4e425df2'), + ('\x22ba3f3500d6c29889d393fd190c7a7f8bb3221c'), + ('\x22c7346693d709ebbf12f21eb9cfe94f3022eb73'), + ('\x22d19c8451ac6cd8d25b3e45c1f70542c626c0b7'), + ('\x22d1cb7773e46e6a6a5fa790cdd5211fb2efc3a3'), + ('\x22d1d433f59cae97e90d6b685b9ce579460cfbc9'), + ('\x22d3ca3ca2b1f61728f039a7c5093c46a0cc1eed'), + ('\x22d65b834a7711e556f35e791717e9a0ecb8ddf0'), + ('\x22e1747e00f6b579d0502d60f63262a8ad7f96b5'), + ('\x22e6f9507fdee0166c0b2516d7c07786778996a2'), + ('\x22e75cf800ba10e17a78ff5efd643c00ab07e7e0'), + ('\x22fbb8aae99cdc0b196e6346449abd2d377d90bf'), + ('\x22fddc4938c00e7a480a609a9b77c127246df38e'), + ('\x23072ee0372de00d9c070f446aaaab795ad55921'), + ('\x2311e1b5a014b7f93e35bd56c88d229f8b11b486'), + ('\x23126c3ffcdb71da5ba07143f853d862189912ec'), + ('\x231bcfbc6ae53399be5f8598cf76554d6f810e9e'), + ('\x231efcac9a968bb49c1d55b8b3759bdc2ff633ea'), + ('\x232184da315f7d135c2b0999428b868c43b8052f'), + ('\x232825206b7a3912d16ad55dc0d0baa7aed4ebe0'), + ('\x232d73d37ba4a9cd4828107eafb320cac9b4bd40'), + ('\x2330c070048be3d0e2a5f1f32fe04e712a55b787'), + ('\x233525827f0d6e48681373ba7f22c0db1846116a'), + ('\x2335496b97e7e81e7226145561daa7d8d4c88e8f'), + ('\x23369ef00dd95a5105bd038fa4f725d73d8b8c0b'), + ('\x233818a74e51101b3b5c834b98212ac31cec7060'), + ('\x233e8c23a5e607ce6c50c5356c9f650e01fbb5a5'), + ('\x23439c12e850587d971e48dc667529d90ce965bf'), + ('\x2346851f93c51a40b4424768e54c7a5243cb60fb'), + ('\x2348fd809136c2dbb848776048b58488cd755a18'), + ('\x234b025a51744b76b934c9e681d1d4440036aad1'), + ('\x234ce246fd26f02b73696262107650dda78e87f5'), + ('\x23582db36088d04ad672e3f530b01fec814eba00'), + ('\x2359c4bd23bbd757c1e3567b03413b7fcd752b5f'), + ('\x235c76168c38a0314158eb84e9c207c53817eb6c'), + ('\x235e94fdbf9d7fa5407588906d9c8046a8bbd975'), + ('\x2363d791e90e80cf3e7cbefbe9085c57c5fd86f2'), + ('\x236bc25f83e05946aa8a3c02831532fae7faa0f3'), + ('\x236fc8483fcdbec3d8d8e249a366bcd842503f81'), + ('\x2381ac15a6416b044d95bd2412519cc76fbcddaf'), + ('\x2381ba924f4f32d68ed8b03cb812ea88b7f32b8a'), + ('\x23832bd67f49d9dc9c64d4ad528806355d4f5083'), + ('\x238412449ed8f29bf63fcfa2621c2175716b8a7b'), + ('\x23849c433831404b061947d4764576f78cf28a56'), + ('\x2384a5f27d6f1e3aa1ab6bd92c24164a6e41242a'), + ('\x2384b8330f55cfdbc784b650031a4fe064e28f12'), + ('\x2384bb4f153dc7d1dd07ebc09b4122ad5a6b1bf9'), + ('\x23871d7ecdc0cfd06d1311ee4c3ce036cd4e1959'), + ('\x2388bd41047ff0944ad224729861f57adb455bbb'), + ('\x238a0c4f238eae0e93ca40edbfc9266494d9e5ab'), + ('\x238a7d357d9670762e022dd691e20d38aec0025f'), + ('\x2392a961a6103c35f0b79b57fdfd9c8b3897ca1d'), + ('\x23942b89ef0b47e8c49bd31d62a6bd9a448a3c8f'), + ('\x2396798ee068da261ac33f584a16ab906af17526'), + ('\x239698b89462b58a792fb55e97b68c43b2047a10'), + ('\x239d9614d69c03c910d725c55a0a405f5be725ea'), + ('\x23a1d0af566918cc68b6b2540326afc289845353'), + ('\x23a651d78b880d9ea81de2d45e682d771190392a'), + ('\x23a83d46b834221219c9fc86d92fcc260bf2f35b'), + ('\x23ab16963ef3cec3745e7edb36b142f7f49bdaa5'), + ('\x23acef59c2eec9e56c37c8e17d25902dc925a6d7'), + ('\x23aefe18b005ff4b72ba3d8b89deb1779567023a'), + ('\x23b1696d27e49dccb13456cfa594340b77bb1b11'), + ('\x23b3826b11e37f0e53f890a0b5797f85920380aa'), + ('\x23b3df2225832bf8b6a5a822c568da3e90743033'), + ('\x23b83a64d1797d5f73f2ea144d39c6890724854f'), + ('\x23bcbe4d894d48c111ddc4ee77708cbec76ed122'), + ('\x23c019e1cf9a380f5c8e2e43cc9a32eebf88a703'), + ('\x23c353c9f4d064c4a8fcc9fd81a61f332d5c1c92'), + ('\x23ca81fc5e550139ecc8a298e8b610c4d18dcd70'), + ('\x23cc91714d652db2ded6f8cd9224f29835c1679c'), + ('\x23ccd825791f64279353f5b59c392e5e2407cd3f'), + ('\x23daa16b799f3e3e0a7a766d5a512536221db708'), + ('\x23dcd8865379d4f23d28d84ba61c8ed1e0da2def'), + ('\x23e1a7fc105fd85a238afa7a7ee02c6a3be66d81'), + ('\x23e1f279fb89f45452c4242006df4b72e5a9b063'), + ('\x23e2305a24c3716e1391bde72ebecb794f2d519a'), + ('\x23f27a50703b5192d9ab88bce3a81ee5bd989f20'), + ('\x23f4064b9a1f0e0d3a80ea9beb4a148b60df15a2'), + ('\x23f7101696380a4db693b72812293e0af3b391eb'), + ('\x23ff0218dcb4504a8e09e86f87529a7e46fa6bf9'), + ('\x2405d071fe811f556cfa64f553092713ca82ad0c'), + ('\x240b45429c3b765c785a55d50c8c121fe5bcf883'), + ('\x240be315761700566c4dc94806d9b8b4d7199555'), + ('\x240fe879097174e8aa90e6c25df2995e8cb96c3e'), + ('\x2410bdd516c277e41f37ae92abca8a1a726ab41a'), + ('\x24121ca5f31d11f8d7cf3e47117734cf4d219c70'), + ('\x24161e01f10cac9e966df4876b3afe71553a520c'), + ('\x2416a964578e16f705e129c029166365775416db'), + ('\x241f60a6732b94cd285476c9b799fbca995ed711'), + ('\x24240cc6a6c5ec975e1febef03bd25d813c90563'), + ('\x24247e3dc2abad16e0e4f9076df82e76498c31cf'), + ('\x2428e812bd35e6166ca8d128b45c937d1baf8b52'), + ('\x242a10383e195a17a28c65a3ffc650d489f22b23'), + ('\x2433d896730d05187d267e06e25c43819c2b520b'), + ('\x243c2d2f6d1c6337b213d75d532aa99608065e0b'), + ('\x243ec3a60e3bf58907058cd9041ad25375d4b1e9'), + ('\x243ec8b1eeb80d9510934f7cb90f82f2dd341540'), + ('\x244151f8013cdf8d97772c8087a4ad1d23e7bd02'), + ('\x2441642c4b1602d16bcf662a8757ab2e916569b7'), + ('\x2442f610b5b31125196a04b1bc04d4aeca2f7a4d'), + ('\x2445255445019c4ae789b16502b85d28b111bcbf'), + ('\x24475a0b11a8b79313d1618916bfaca5384e836e'), + ('\x244eaf2124c0448fa93e5f9d4b90aa41ae2c4053'), + ('\x24510cfc6d1093fceabb30fada643ca4f625ef4d'), + ('\x245300a8258a486e4884e62da023f2194cd69954'), + ('\x2457a0967b62cb6c4b5c3db087d6b4f208198c27'), + ('\x2457b54fd9d74f1f593502d19de6cf5b1987b73d'), + ('\x245c022fa064ed06f00ba745191ff5475d71d2b7'), + ('\x245e71ce473f2f4dabb48f5ec3ab5dc6f2f0ee43'), + ('\x2461535aec36735e858da4c84da3f85bcf7a8b6e'), + ('\x24654f12a66a1f07ce0fbc6150da3ebdb173b815'), + ('\x2468df535acf5acb21e56a64f10b3e8d1db8f2d6'), + ('\x2468f9bd0a4a875c67751180a60c6caca66af58f'), + ('\x246c5ea02454284062606ea748201afd9541a251'), + ('\x246c9b5e097eed699096c004b75e658d90da9701'), + ('\x246e54f889fdd15aef7b853d4fc06ae7e109181c'), + ('\x247456c515682c2e68ad4fada2a0f01e4ffde63b'), + ('\x247cb2c6db763fe25689206429ee28f112ef561d'), + ('\x247d6ffaf9cbfd0d110d3557905f4060047b31aa'), + ('\x247d972a9bfae0ef0cfe4eadd456c42ea2bfde67'), + ('\x247ecde045f32bc0e37203c49d6280affe1b5aa9'), + ('\x247f41b817f810115105c55c3887c5b02bde37a8'), + ('\x2482be49fe0a8165c7ed4195b2ff93bdd229132e'), + ('\x248b0d7b3d8011a370450aa356632b8da9667d16'), + ('\x249605e18705f19a593198c67f1671c2b0adbdb8'), + ('\x2497c4bb64f2f60a0851888dab241b369749cf34'), + ('\x249f6cf740b460e39497b341eda19cbf292a8fb3'), + ('\x24a36b556b149c01ce7df118e4bed1eb916c10b3'), + ('\x24a7f77445eb7d755713ae46a098f6e1475a65bc'), + ('\x24a911ed8ec6cc713f4195651d3b9756e8b94c57'), + ('\x24b7b0c683a17201772508c43dbc2a4d4a0d3b5b'), + ('\x24b8d23b7ce70167501e861023284fd4e735742f'), + ('\x24c103b1f6bd28c83dae1d7e72922322ede228f2'), + ('\x24c5b882db981332d1e8e3f1e1554e2be3a1b985'), + ('\x24c8954b53c9b149855cce8efc56f7bfaa309c5c'), + ('\x24cd249d4404b537365f21891bc882f19f26cd0c'), + ('\x24db6ec9bc48802c71ef446364e69e3ea634b0ed'), + ('\x24e3eee9aaec7ad5cceb9c4f33f09694b575e3ee'), + ('\x24ec6cd098739cef112f5c56c2e4c9a706ecfcb8'), + ('\x24f0d8c07a01baf228aa094a34c2c34b94734cbe'), + ('\x24f1b4e5d966baae53f9f96ac4335da3ed55befb'), + ('\x25016fe9ec13121766b44fc44a1c51d0115c79f8'), + ('\x2505554595c49bd40d3bc5fd2fb071876e982823'), + ('\x250d7599352f2145605095d4a6b9e69263156afd'), + ('\x250fad4d7b53302b8abe400e1c4741d7d82f6f2d'), + ('\x250ffb64448bcf453a11a4d409dbf517e3c30ba4'), + ('\x2519179d679cea7e08a2e70e2f818abbf7908fbc'), + ('\x2519a36ce06632d11e32e7e364931a2f0d000c55'), + ('\x251a2f62d74631966c1e65aaae0275f2845ca761'), + ('\x251d8d3959f92450ccfea4b1977c2665e23a8f50'), + ('\x251efd520379ebc81629f7b413386f2390c1aa35'), + ('\x251fcee3c4481127767062ae6e8c034c34369fa5'), + ('\x25238abff6a1d44052a7214612139be5eedac722'), + ('\x252e4d3971da2780180a20965557447cef6f816e'), + ('\x252ef49a9d135f093f2ae39fdb66ec8b00c87c03'), + ('\x253311e9987a21f64fcb96cc319ad0e496f07f50'), + ('\x2534a956b53ebc9ed162855d57084b1880eca962'), + ('\x2538abae3c83ee4e389da765989416a906378f1a'), + ('\x253a876d8e807dc7ec076116ba3a0e0115afa4c0'), + ('\x253b119a85d88383042c678be46e6693784c1124'), + ('\x253bbdb398176861d4aa14c295c527f69d252129'), + ('\x253d0844f3bc8ff3839fdbb7478881a6bf03b767'), + ('\x2548ad44c7aed865277c1ba180084a9121075bbf'), + ('\x254f212f566862d78513e2478d0dec4de373c1da'), + ('\x254f9857e2644dc9d6c88ee9aff64aa936ceb2c8'), + ('\x255470950df10e142cbce7af9e3aac1d1342fc32'), + ('\x25601131ac8491aa3dd6a08ea08c527199339d61'), + ('\x25612f1c641afda9f04c583da182db60e734637d'), + ('\x2561b5e11c3f8614f253da8846bfd123c4cdcebe'), + ('\x25663ffeedb7787c8707678d6957bdb72c07b50b'), + ('\x256f343611054cf9e0564d4aa684a904ea1a6eb2'), + ('\x2572d4b0bb74308e8c54f6fcc99393cc4085abb5'), + ('\x257b65a4a5030ff7ba3b2063eb4b906b11cbf263'), + ('\x257d1855cbfbaa70ef0d06e8dec7e9038723f7cd'), + ('\x2580056dbe972f4c4053c28d9bde709a0cc485cd'), + ('\x258515787d2ac1a1500cb3e93ced9af56c2b1a6d'), + ('\x25858aedc4b38786eecf2b5b15dd05cd1ef63d87'), + ('\x258cf66f8fe6940f17f50eb60fadb65d49077393'), + ('\x2594729884ba71b2035340e2478727da856122b3'), + ('\x2594a2922c265e48a418ca31c0e7358a11197756'), + ('\x2595e6f972eac809a3ba687faf9dab6770589519'), + ('\x2596aa6e09170fa92a8587a5fd5697470e2e8a9d'), + ('\x2598eef95c0685ccba1a32f187e045ebd2279961'), + ('\x2599d6c65ba94d143f5a70ff4983b04d4c959bb8'), + ('\x259eabf6e78b15e0c1ffe00c68d16f94d1ea7477'), + ('\x25a274cb7c3e686fc5d2523683b1ece0a7b35515'), + ('\x25a6273a6600e15caeaf36c55f55f2bb76cc66b2'), + ('\x25a9f4c6a0cd247feb2768e5960a077c0b50e7a6'), + ('\x25ab021549dd2280be10465651cebf9d68a36d82'), + ('\x25b413fd96b747136af4af343a862a9141e51db6'), + ('\x25c43114042c6c6ab542a86dfe6e54ffdc253105'), + ('\x25c5da70bbaf68cb6873ab2fc3162a6578fc9739'), + ('\x25cc5982bc7bc2faa2b57e055b4389d4c1f3a032'), + ('\x25cf851474af61bdcd0a95d4fb87a4c55ca24c27'), + ('\x25d45c32da2b742005001a2559d55627afad662c'), + ('\x25d78dcb5402c8f487ef7b3653fa0ebf85a7df16'), + ('\x25dcd621b3665eb61a80249c193ec70c8bc45b02'), + ('\x25e2449ac5d9decbc905363ff296c059c5956bbf'), + ('\x25e6b608d4df8e09eac35e961fc26384a0fd0086'), + ('\x25e6e62920b9c5f1f72646845a1a33eb23b3edaf'), + ('\x25e82fcd270d5e979895d9618469faabc801b700'), + ('\x25f2df6adf787e958dfc4ec00eb25081b8c1ec19'), + ('\x25fa6459e181dfb2fcd6e580ac5ce4a18ea5dbf5'), + ('\x25fca85fb01e1d51b5e59fc0a04bdbc5b0b47dcc'), + ('\x25fd2feb18289baf202cd148c5c93cc982ec8810'), + ('\x2601cda0c4c04f6ad7d2f17780cda8116c697fe8'), + ('\x2606eb153a1a3c687c949384a8f76f11530faaff'), + ('\x260896b25a8fdeb67a6d68021cab35d8f84ecf82'), + ('\x261193c3266f2a5c39e2f349e7efb9e6343ce73a'), + ('\x261794094e2a50bff1eda0f2848e4b4255b077a9'), + ('\x261e000450f5a30c47470a402725af78afa62ddb'), + ('\x262466746d9a60f75bb70bcd835fb52f88c319c7'), + ('\x26299e9dbfadeefdb7ef44aa0903898ec9677cc6'), + ('\x262a82b2b10fefe045902a67b9364485e8083172'), + ('\x2632d64cac935f28f773f08f50ce5ea43c840279'), + ('\x2637cdfb85d690d2a7fd8e25a4579e208f99eb39'), + ('\x264c027f586ea39819fb663f40504da8b4b0ed78'), + ('\x264dea006d49c098282e17c108d5bc9ee92e268e'), + ('\x2652c05b5eeaddfcfeead859be2fdd19dd497b5b'), + ('\x2652db7feaa99869f92cff915fbe6d1dd5a35838'), + ('\x265900f61644d9fe06d55ac6a5a5c663c7f5c355'), + ('\x265c6ebcecf9c7e8bb9d2513280fa1480ec6efc4'), + ('\x266032294ac6cfb21c4f76a0a2ca2c7d17f5aeb4'), + ('\x266a2f5113b6d32fc455c92db7596767849d4735'), + ('\x266aabb43163fe83d9436619492425a7b6234db2'), + ('\x266c214cdfe8fe3a53b5cd7f518a8528bfef42ad'), + ('\x267654e73dbb9e5a565f98e28ff486b9904396da'), + ('\x267774dd3bc3191f86172534fa53921239d06b81'), + ('\x2678a1c02eeadefceefaed80158c23dc98420360'), + ('\x268992f92b3f337df678a1bbe56a43d33fecd18c'), + ('\x2689b49f28f6557693221011409a2f1ae3d65274'), + ('\x2695cb0a54083a2ce99e56dc099009992258887c'), + ('\x26997046ce3e9901307d7f5f65b2b5e58430f0ed'), + ('\x269c1dc1dba0653af67b74d86ebbfefddbb9c618'), + ('\x26a0e44702a960202ee6d8afc80f6039ccc6818e'), + ('\x26a61488f0da859fc39715f2f31527e461fbebb0'), + ('\x26aac35d538cbdfb39ebe7ba4194b44a0b2bde55'), + ('\x26af3feb6d39e3303418b37cd1a32d8744a1c285'), + ('\x26b7868ee4c5546d4331384464505abe37f9264c'), + ('\x26b988d3b8c09e7986537f650613902c1c878459'), + ('\x26bdfe6c3ca607cdcda6a4d9f2d0e20ae71094e4'), + ('\x26c6fd0f5d8f9dec0607f13e349f8d5223855946'), + ('\x26c7fc402c27f349b765c8d3cfc20bab6ec66349'), + ('\x26cb477869499d4e9983b2a456e1f5d32739bf8d'), + ('\x26cb9d48a45940c78158d92cdc3bb9520d8c7a74'), + ('\x26d87c16cc6a03e734ba27993b9bf51727dd8722'), + ('\x26d8d99eb1ad8ddd60579ae0475025a75c4009ce'), + ('\x26d90ab5c679ecd1f74ef34bf034a42ef921be02'), + ('\x26dea7951a73079223b50653c455c5adf46a4648'), + ('\x26df45f1eb2c7a2b8ae822f02170f5864fa09dec'), + ('\x26df70c7636cef4892d2c624ef7cb258ded880ed'), + ('\x26e642f6fbfc3675953cbedee3b0c8422056b540'), + ('\x26ec84a030308e3b9daa220483a7054c2de8094e'), + ('\x26ef29fd3abbeeb73c5ccc52a3b17476dda5e2d1'), + ('\x26efd1b7652f5c17b9e93a8659f385578b596848'), + ('\x26f11f7b3081b3daeaceb20f94b3df5d62860e2a'), + ('\x26f7231574688da2648ea3440df8fc960a7fe0e0'), + ('\x26f74dd02bc41f6a4875dffc28ef91001182f70c'), + ('\x270090793fe15cd85b9d4e128b8184688a56f813'), + ('\x27029dc5991f7298724a10fef59e39869a91ec8e'), + ('\x2702ffd8a7dfbe271b39e69d4e1740bf91d1e333'), + ('\x27048a28f08c7f2805f883cc0854c48b4a7b4362'), + ('\x2706c0e08bc8955eb23cdaa69115a4d87cd26ee6'), + ('\x270c4ce0acd498c669ad1b7bcbfcfc3d677c03d6'), + ('\x271042fee65f279ce58150c15e85ac05b2456e1e'), + ('\x2711ede34bc9969c6cb80731dee9651787b813ae'), + ('\x2716c6d3f1ea15882fef35fae9cca61ab57c9db1'), + ('\x27227bf10e4c4a210d97e94e49f7305ffb7087a6'), + ('\x2722bd69719b9d1aacecc5d495db6adcdd494273'), + ('\x272b6364230c376c0b245cbdeb934a1d3b054980'), + ('\x272bc423bb89887f2ab5594b45f4fdce1675e4cf'), + ('\x272c50057e5be95edb00b569e1ac2a3f30100081'), + ('\x272daec162d04963b4265027298d1af1a336ca8b'), + ('\x272f219b38f99561a6b27fe0f477145ab408fb7f'), + ('\x2735f962481d75745feebbac51c7a58a5cc7368d'), + ('\x2739e8d413c3946f42e7ce1e7bb5bb5de6248b1a'), + ('\x273cd47d3f316f253c264f3d25ad232d508f9bae'), + ('\x2741391c425e6065ff4e5ab4164d237e7b758c34'), + ('\x274a9b01dedcce4906d6b01506bac507d2b896a9'), + ('\x274c3467e407c8762bcd60e3de71fdc1353bfde7'), + ('\x2755f031dbfa719bf5b92fd719172909e793114e'), + ('\x2755f9b843ec15e8663336d914600dd1a392723f'), + ('\x27575c60531cc7a0aee42b151e4ad2d1a66c4a2f'), + ('\x27580a09a9eb648f5c0da57ba33480e0f560b508'), + ('\x275d276209b2f86cd2f44d54350b6ed6bafdd01e'), + ('\x276977f19e6887534616a4f95516ac16cbf32972'), + ('\x2773c1691e6dfc561f39aa982f493af92749980b'), + ('\x277809cf2bcbcd30504ea874d61c20092aefa9c7'), + ('\x277b2485c3978ad7623b105b2565538a3d3d5663'), + ('\x2783cb6409ab2ec12e264a22c7f050ac3f4846c6'), + ('\x278a99df300b90f193cdd7cff4f7efa53a818d53'), + ('\x278d2db841a31393a6853af2d3e136dbdc0b5b5d'), + ('\x278dae0b2833406aa27d96e1a4b9a9ca014e0310'), + ('\x2792742e5b4435829e0f1922390486ada897cf39'), + ('\x27974b71f37fcceac7844adf2ef3823ecb9336da'), + ('\x2798ce9e3be09b04b6509642c2709f148e643a92'), + ('\x279efbdea26fa80cceec6bff35822d6f245cf3a0'), + ('\x27abc9de979bfea87842b53f74b9a45d6bbb0e9f'), + ('\x27aec2f89ddc0aeaa23dcda30f170b5e52cb526a'), + ('\x27af1482a378f64376ecce91c122f8a1dcc4a841'), + ('\x27b2f2ca2b244a8b17b5b2835e2c861091aac1ff'), + ('\x27b4bdc54c572cc85a3b0733de51738be95e1232'), + ('\x27cdcccb6e4ffa3dc70d2ba054d354d5f97b78fe'), + ('\x27cf5895717f976ee7c81d3f3a3b2fc272e0ee8c'), + ('\x27d3133a3832ac05b9aafaec2260ff13297a422c'), + ('\x27de69e098603df022f2b17450626e2d39ad4363'), + ('\x27e1c61e0ad63c87f6d4a86eabf32ea8ee054732'), + ('\x27e69f90d63f5ea3a6615787b44ef0933cdb9b08'), + ('\x27e78b559db7be3c6211c86d17cd0d81912133d0'), + ('\x27e808175650e420f64c8262b91a6fa3240a561c'), + ('\x27f3c2cb64794220d968005953247cdfddef88a0'), + ('\x280ba27f974e577cce9ca93f4f8485f110a01588'), + ('\x280c1bb4bcb4e404728f9da8c96bc5cc5b581469'), + ('\x280cbc26dd8ca36409b3b56583bce6291bad31ad'), + ('\x280f5019fb268772d4b9e1efb32182b6865e9d6b'), + ('\x2812c36be9000fe7a32b60b2b59f18cdb275cdf5'), + ('\x2816d0d60481eb69f03d6665a147e2b0a2177bcc'), + ('\x2819bfa08ff8dcb7614614bb18b39a681509e8d5'), + ('\x28254ad403d2f2f6bf2061c27d983b3c9556e415'), + ('\x2825b53ddc191c03e01fe6ca37e1dbafe2400f56'), + ('\x282b10706c874df7490236ffea58c4e0ab2f95d3'), + ('\x282dc0a10cbca8d3585d618855b086a0072fa812'), + ('\x28357d8c81ec2c908958df3889e71b139992ab8c'), + ('\x283e9269dbd5f63a9a978655ceb4a3c7983cb1a2'), + ('\x284a82efdb7f21b67ed763b6b52aa5b5cc335112'), + ('\x284c1db431d31f4aeee97d5502f5d5b4302a7068'), + ('\x2850081b53f1b3352e7cc9d516877d9b2a82078e'), + ('\x2850b098be2532f50670cf71fdfb5b5352cbd8e4'), + ('\x28510d11e66c98b053b1c36a6c9005b016b76514'), + ('\x28572fbaa0e0a10403ac0ac993cb4a1f86ad1e33'), + ('\x2863e86398cb7e51957e29920f5b147e2d1e4a96'), + ('\x2864e603c7c3a1505d2d12fef00d84a170d6a157'), + ('\x2869fcce32234d2a4ba9a16d90c1c5288981735c'), + ('\x286d70ef52f5335490553bfb6b4c58d7236c012b'), + ('\x2870a157f7b463f57cccdef13fb5caf69278ea5a'), + ('\x2870e656d34e1e02241cfaae1a9d69a961f100d3'), + ('\x2873b61a17a6699968db5ce16179b972a1b850b2'), + ('\x28766bcd8508e8aa255246ea6cff6d1ae148d138'), + ('\x2879194c94330ae0b5d286467d7f32dde0f6ef7e'), + ('\x28798d3c6231092583abac946da39b73633bee56'), + ('\x287c8e493f525b5a74f14ce3d90068d4652494bc'), + ('\x2887adf0d00aab499ee6ba9467859a9dc41ca37e'), + ('\x288b573ed8ff2e7424aa9573af4c9c54c9f1b74e'), + ('\x288be29553a742b2a9caf6f2e37144507eace4c1'), + ('\x288f52f13b7b8efd03419764acfe48a593f07948'), + ('\x288f5d0ad4b0f9a85d3d9f0792e295aea96857bb'), + ('\x28911f58bf049fa37050591ea21ca23a64acf428'), + ('\x2899c6600af7b66adb1f267f2c3d6f43b4d5d8fb'), + ('\x289f7a68d2cf2b5cec7c492f081d7ac59da9159a'), + ('\x28a2c54a0a9722f4959a9cefad10b02383ac7953'), + ('\x28a7b79a8567c870f6d89b969edf8f23a9d51f43'), + ('\x28aa02279c56f5a073658ba3d6368f10e0bae1b3'), + ('\x28adb7cf298b68ecd473de7193b861e822b13a04'), + ('\x28b342e6682777aab944650ca7e64f14c2994a27'), + ('\x28b80d80dd3a0a9e15a54e17b570e8c94e16e39d'), + ('\x28bc535ba5f918383c29d01f2e7801c5d9f6a1da'), + ('\x28c4620e617e87ecc2deaa8c73d687620c496992'), + ('\x28c462b11b1134bd7804be27e38c83d070f6727a'), + ('\x28cfa2ad8bbd8e6fbedcc687e4e31b5b65fe1412'), + ('\x28d59c84ba2a9d5dac9750dc135897451abdac2b'), + ('\x28d7e214f383969671eacb6d9cd7b714934cb91b'), + ('\x28d82acde12a4e237a8b9fdfe9b14752fece9645'), + ('\x28dce47b32b4886da105749fd6aa7837ce3ae6ba'), + ('\x28dda658e169e12659c91c9c163478a39fe74147'), + ('\x28dfbef748831baa03b8aa281af1fa24c1bfea94'), + ('\x28e48f4540ea2b8446f7ceacb3f992ea38969240'), + ('\x28eab293089bbbfc992efa70149af8df86f95db9'), + ('\x28f10b00df56d8d840bdea5bc4820d939ae306d7'), + ('\x28f5b2c1d58b4e57b1416ac7136d699e2ca629cc'), + ('\x28f5f89ce45642549d0b73357a2d4cd9646506ad'), + ('\x28fafa4d0df57f520011b114fa520b6b456c0fc6'), + ('\x28fe0424f2beae66099ab34ab2141488557c9501'), + ('\x29086617cdd9ae627d93b2291dc17f802d063a5b'), + ('\x290883f17bc76b42eaabb91159fa69155d17b993'), + ('\x2909779c2a55d1f7a821f6b60b7737ede9bd02a0'), + ('\x29131d4dc8eca48088e084c8eb0d152edc729f6a'), + ('\x2913600f8d23bf2f79151d51c723ac9043dd15b0'), + ('\x29214eff7c934bb61997145ce1e62a8c10670b92'), + ('\x292481ea48e09213b02f670ef9e34b54c0be74d7'), + ('\x292aaf55f83bfdf0f60553a2757dac9e6a57ddbc'), + ('\x292ec954bc2c762964976a8c643ba526b47bf190'), + ('\x29315af23b89f95dbddd1e58d98e4eb4099a901d'), + ('\x2936566c07d94184002ad9036cfae313425458d5'), + ('\x29372b09f3eda89fccc677328411f106a691f3cf'), + ('\x2939e58976cff8a156c7df4feace2b1bd432861e'), + ('\x293e6e802e632ad334b3648e9c68ebe54a31d781'), + ('\x294055306475f9e242d3e4655101186d8d324c4a'), + ('\x294777e6cf2527a4ca611f0128207f3e663685fc'), + ('\x295960e981b1c6861f1945f00c428acd5bc609ef'), + ('\x295a913ac11dd86b27c3e607d6bbe1a459d2fdbc'), + ('\x295fec6a4678ef02a7dccdc58e73ab4f12e2843d'), + ('\x29616bd30fd6a36ffd51ef992bf141180460fc9c'), + ('\x2961bb779d96117014f7b5ad5f8d6e949c6b81dc'), + ('\x296ade444e4dbc204eb2250e6ad9ef19ad0cc19f'), + ('\x296fb4e4315c1815e82634e53e25cb550c306379'), + ('\x2972589a76eb8d1acf846377cf884efdbe7a10bf'), + ('\x2977b8815e208e21c4d77fda601f47e180c2361d'), + ('\x298250d497443c8a0a7a1126c5952e9956b3c773'), + ('\x29852d7488ec37e96335036afe6320a018ff90cf'), + ('\x299bc88dfc3f711732273d81537c494780a7dc5a'), + ('\x299be00443a600eb8a5d83b942c33b971d0e3952'), + ('\x299c2afa317fcfeff10c02d783e01d96eb6f798c'), + ('\x299dccddaff97268df4e932bddc39f44c73c21d1'), + ('\x29a05db7d653f8ca11875f771b979d511a59a8f6'), + ('\x29a14a34157740500242efd21ff1775674a5fa7e'), + ('\x29a94047286ab0c704ff3b8bad33a87bb769d224'), + ('\x29ab0deacfd2776a127b36e0fae4cf07f644b2b6'), + ('\x29ac73a9a3737afb181aeecc5622776825216c90'), + ('\x29b1130bdacd864a8701c183550b38edb077d98d'), + ('\x29b72a872a7ffbbd1faab90636414002f0c05faa'), + ('\x29be11a1a9d614fea56d72d2b40f394a507ef9a9'), + ('\x29c042d6f97698f259e57962d60d32c13be8aeff'), + ('\x29c14cc9ce8269f832fee98fe864261ebfa7f877'), + ('\x29c839bc96ace23de658b1ff0d3dadb5b1404ede'), + ('\x29d0aa07e718c5cfc6969ad15cc28eec0c04f4f0'), + ('\x29d25c4f4347df83234034cf2aaaeaba87be0745'), + ('\x29d2cb4ea4fcba5770c6f2877c2bf96cb59ac9fb'), + ('\x29d536b9ff983251b786379969d0779ead3e8d6d'), + ('\x29d7b9fad7c124c57c4eeded9e76285cc485c5e7'), + ('\x29da936b069ef4441eb3fcca2b49db3eaeb46097'), + ('\x29db2b7e4ba8eb443bf37b53177be07657330c4e'), + ('\x29e182ebdc34713795b11c2316c20df18d62ac36'), + ('\x29e33dbafa158d175debba7be7d4721d2bb734bb'), + ('\x29e505e60de79ebe2010b8099ab7c49a8187038f'), + ('\x29e5b8c63f0d593c52744c29a4d9fd865f71af2c'), + ('\x29eb80dc55264caa3c5b657c7f73c8a1d7983617'), + ('\x29ebe4355dd45628db3dcad94d50e551e5d8a031'), + ('\x29ee7e75fd14cf3391a6ce1833a0d8bc1c13a8a2'), + ('\x29f132cd3d188cf8a36596857d95113b302e55aa'), + ('\x29f449c17fdc6451cf6e4a09942daf87fd2eadb1'), + ('\x29ff3a27f13a460c22d27caa8bb3fa2b10a2cbaf'), + ('\x29fff43aebc821e01c72a754b027fbf60e9881d9'), + ('\x2a157b8b1a8e45d97878286e5eb5621d8fea781e'), + ('\x2a1f46324e18f92092e267845de3af7a9aa3e32d'), + ('\x2a28b9e65fb956bad18205bb0983b194381ad4d6'), + ('\x2a2b9e71270b0262204982a3dce08d70a5162922'), + ('\x2a3250668938572011ab9ad35b51691558148db0'), + ('\x2a345b95a0d06b0f8ee155a2908c7b6c01deefcf'), + ('\x2a36d9a1fb254aadec37d26cc5e2c916eda302a3'), + ('\x2a3746eaf0913a6f6b2abf2015f120ea837c4322'), + ('\x2a3bd02679c5cba1b102787fd698f25e2d7c30ab'), + ('\x2a3d7991d328a33cf49e5caf11bb4b9dcafd22eb'), + ('\x2a4438eca2928b565ca1fefd8dfc5dff210f67d6'), + ('\x2a4785aa9889979ff95aaf9b29cfd5bc7fee9f9d'), + ('\x2a48087f74282759fc800c8f8a9ec8f198b95208'), + ('\x2a493bb0a23e19ef2d83643b5dbab9c585810dfc'), + ('\x2a498bf47f15e9bd35bd42a69bbc5b65c742792f'), + ('\x2a507853b96a5242ae620f96d481a92904e6ca27'), + ('\x2a50ee48dac32f74d67ab8c23c901cf0c4c60ff4'), + ('\x2a530bedab0d349c202908030c048a67d7f9aeba'), + ('\x2a54ed3cb9b96fc069ffe019f4c51e76f0e8f31c'), + ('\x2a5979d3c64424c242f75b44b4d23e648ecd575f'), + ('\x2a6e5a195917c5cbf5bf9ef79ace4381e7cbb13b'), + ('\x2a70ec8e90be6b7518e7355d7e6c847234dd950c'), + ('\x2a7456e47c8dcd2d1758fdc8eaa0690cc22ee52f'), + ('\x2a769461dddab45bd7e98013a93710d6aecab6a0'), + ('\x2a779e7298c98a626fdf022eb7fa80cb343a4253'), + ('\x2a82683ac053e431efe65cc41ecebb35d2ca92b6'), + ('\x2a88240ad502c37aaeae1d4e7fb9a4ccdd548253'), + ('\x2a8b1e7f459726d8cb2ffbdba6ec1d9d6a4a70cb'), + ('\x2a8b98dd48b3db750b119e04251e237b3394ee3a'), + ('\x2a8e0109baefdd826590565a783248b6c6ed9ece'), + ('\x2a927e92b76c38381aa750801f940570145ca8af'), + ('\x2a933e958bd0e53eb22a161a71941344bea6e866'), + ('\x2a9bf4ac201df140de34ac72393599413cd475e8'), + ('\x2aa2b4804465b2c3abcf61fe1f42238b701b508a'), + ('\x2aa64ffe2641ac89dda934bc03ba353edb1637df'), + ('\x2aa6ef8500a97c27a5f8dcfd97a046d76856d2f6'), + ('\x2ab1a9a5302ea80a25a945ac99f9517185114b00'), + ('\x2ab54de12041c026a0440767c71fedefd475c66e'), + ('\x2acb734289457aeddb91e1913ebedaa98a6203e1'), + ('\x2accc21a29798dff416995acdd91f0fd091d74eb'), + ('\x2ad3f92a9dcedbe900e996109aea1a67605db2ad'), + ('\x2ad54bf51d8a689edf648cd9ef1ea1ac98390dbe'), + ('\x2ad694e70e8f75f548d0aecd3f8ace6db000b1ae'), + ('\x2ad69f67666885d1777e25f70854a5b062b00ec0'), + ('\x2adad81103209039b336e485c0b58193f812c644'), + ('\x2ae05eba6bc9443eda1b6aae4bdab84d34aa99dd'), + ('\x2ae3673ef5da2bdc52e571b287e6e75cd5baeb46'), + ('\x2ae579e43d54b608d68b926961da738a75d002d4'), + ('\x2ae6663113cd82c585895fd01b7674be0e53799d'), + ('\x2aec12b77d7205ae0394a8e91b8c021214bf9b3c'), + ('\x2aec71602e1e7ff45361f7c88c751bdd7b79f791'), + ('\x2af9f0a93396ada31f96caa0cfbab287126be182'), + ('\x2affaf1e9ca9ed69f1611977f56d7f0d6e41e931'), + ('\x2b02ecf6f1fcd2cba83b972bbf4c74abcdad820a'), + ('\x2b0570122b061d5fadb5b51fcca492fcf02f5368'), + ('\x2b07bf8ba18b43bbced0bb3ad8d1a213d238724e'), + ('\x2b11661be2a5c5824722689a2be1face346b8618'), + ('\x2b15238788ad3147856fa6aa79fa7420328f559d'), + ('\x2b17a8317683c02dece75c5f6c89623a0d20a420'), + ('\x2b1939b0791d7df343a54484bdf5cc06095c03ce'), + ('\x2b242086a726a489bb0ea8dd355e48f019238945'), + ('\x2b27460fbc15dd363061acec4d85de06b11f07a4'), + ('\x2b30d928deeacc600a6d061d3c4e4b9a7be9d13d'), + ('\x2b3bc9ea04f8ccbddd101cc7622a892dcf883327'), + ('\x2b3fabd964acc470b04ed2576aa87e5bd73ed5e8'), + ('\x2b4560ed4273a26b17efbda54601e37a8669e595'), + ('\x2b457940880933bc3fd8e6cf06d9500f9ed6b6d4'), + ('\x2b4bb2359f30a0b23c9a5fc981ca2afc1fd7c872'), + ('\x2b4e868726d55056614289563f9e97427737b046'), + ('\x2b502b72296dc54d06d80223a4f88f5995a88690'), + ('\x2b57bd0ac0d3bb071f7dee139c7db46c18f2a9af'), + ('\x2b58b3793ddfbc8ec63a579f492ea5ba2380034d'), + ('\x2b59060abb4475102a516759d15f5df2071bd627'), + ('\x2b59eae4195d1cdbea375503c0cc34d5631cb0f9'), + ('\x2b5af1b37b542921be39abda16f569bd4f2fb888'), + ('\x2b5e31ff6aa3ba0c8e6e69498161e9c7367e8694'), + ('\x2b641a39d2be6a8e7656e2b1e4dc1b28a157879c'), + ('\x2b74a72a8080bf53adea90bd64ff1cfbd54ad2e2'), + ('\x2b7543cb2ecac7d4feb6486d53d3eeb23d7d381e'), + ('\x2b75fc40a4eaae56374d3707c6cc100701c162b5'), + ('\x2b764740505d0e1aa04c9bf4f1741ed33e079134'), + ('\x2b78f7908c92de325522117614d3894a4338b36d'), + ('\x2b7ca8422c92fe361ceb5759fbb2571966482bd3'), + ('\x2b842b5c2574a1570d49493dbda06ecd953114e9'), + ('\x2b9471c9fe5e8af37cc45e7832837a3504b96630'), + ('\x2b97096793eb9e60fb36bd317d5213aabc6efd24'), + ('\x2b99471ac54af313d89a2b333360c95dfef3a0c0'), + ('\x2b998863a57ec84e4c3ecaccf3450939eb567e72'), + ('\x2ba090655e92e3f7494218cf16ddce1716747f1c'), + ('\x2ba5463e34f055cf18a94ab97051c88098c0e2dc'), + ('\x2ba6df22af3aabd556f0e2256a873d7fabf4a195'), + ('\x2ba9befaebea6a520fb241e9bf425b78241276e9'), + ('\x2baeb69a353962f1321a43c1bc088bc6e07ccdc4'), + ('\x2bb1deba463202c56036337ea49f6b0b13cee060'), + ('\x2bb49927780c043cde617dba313ef1c886e12931'), + ('\x2bbfcab05ffc4da81780440625d049d2334be0ff'), + ('\x2bc84bfaa864461a379131429e61c591a5c470c1'), + ('\x2bc893e121d149a687ca1b1abf9a3ce23b818eef'), + ('\x2bd018a4e5e0245129b9a2bf1366e19e7ff78747'), + ('\x2bd15c3cfe7fe9312b984bc101e8f209f4577b96'), + ('\x2bd778806dbe67d5a76c6f02f07aae153241e49e'), + ('\x2bdd10b3441556a661594dd27edda95332fd8231'), + ('\x2be4153f95d238278c0c79149de4a2e152aafeb1'), + ('\x2be6918ec086e3ddb056ac37bccca27060512d06'), + ('\x2be6af32e0bfc9b5d32276b8520b366ed90365df'), + ('\x2be941a7c2f028a3e326a2f932cba87508c57235'), + ('\x2be9457ad1b47cca9b89fffa2e4d94f687b62085'), + ('\x2be9ad70108985bbc097453e3f5a8e6f4e67f897'), + ('\x2beb3d69d4b6d1db54cd5ed84ad6471e3d80e25a'), + ('\x2beb6a4fa545b8a2c64eadb9ef409284e3d8395c'), + ('\x2bebf363e261a8eb1129f898fc4d2800d69f9027'), + ('\x2bfde492ef48e5cb306a69f0dfd5f5f5da775251'), + ('\x2bffc2209ba509dc6a801d9f40c70ee838eb9c26'), + ('\x2c0a52ed107b8977f3c8dcd2b9902bd9130e95ed'), + ('\x2c0f6a96659985b3d2f0fead850642deb94f9fb4'), + ('\x2c15a629f48fb9d82a7f067415d88e913f946908'), + ('\x2c1909c7ed5580dbc22671e12d5945757d533c43'), + ('\x2c1c35245d8fe716b12e6d7412c83ced0bb5efa3'), + ('\x2c1dc60b32c58aac48ba23bae12b83ca0b822e8a'), + ('\x2c2136eb6080bcd7ef555848cf0bc5ee773766ea'), + ('\x2c2501438c659e1f8989d184f1dfb58a547c239f'), + ('\x2c2f50deee47960d31e8966756eb98c54a3528ef'), + ('\x2c31ac0357a170ad035d554465b44965cc58353c'), + ('\x2c337f4f0e701439ee9986285713dfbc27b41438'), + ('\x2c35c73c9ee63fc459ff904672ab4f6ca8af1004'), + ('\x2c36d297ea5debd22cd759928ae008e75186e54b'), + ('\x2c39e97a9774017779bbaf085681b5d622039218'), + ('\x2c48931d0ed9fa818d0df7d53e7e966326a2c2af'), + ('\x2c48c5c44c265c3154cad75ff4175d55b2ed6210'), + ('\x2c4caa25376d23584a3cff9cd3085ed21b53c80d'), + ('\x2c57d0d6edefa171e26d00a4e9b453be7b4eab1e'), + ('\x2c63aaf3513e99b68279972ba6fe021eacf352ef'), + ('\x2c67809779d7f1264d4e45fe8c158b59976bd251'), + ('\x2c6f458187923bc7a9e42714145af514775632ca'), + ('\x2c7427ef023de129dd8dd431bb5ad249068e35e6'), + ('\x2c7af56479dbf6597875ce663ec93689554d142f'), + ('\x2c7c116c4b6cf240aaa663034eed6391a4d29741'), + ('\x2c831a87a1b449a0cb73498671d320e125034eb2'), + ('\x2c89f4f995b4203f4a6a395690ccb5c82e443448'), + ('\x2c8be778338602b4141581d628135850d0d94088'), + ('\x2c97eeadffe1a34bd67d3ff1c3887fd53e22c2ca'), + ('\x2ca731806cfb55130e1e829b04599b8785730603'), + ('\x2cafdb51ebfa745a4cf2b4873c09c61e18d4034c'), + ('\x2cb192ce60956fb18a3b748ef045d42d8434e315'), + ('\x2cb2df058e99b8346a295aa9e607a6f69f80ca4f'), + ('\x2cb551561d1ed9a549e2c390e9e9179d2fe98c9a'), + ('\x2cc2786349d37e2adf17f3df3e8482936dd89865'), + ('\x2cc6050902e326e4d07128be3b701be617723f7f'), + ('\x2ccca45bab502f8b4f534b7a49d659100113c450'), + ('\x2cd2fd0058b2d189215b152c37e6c81a107a4ac7'), + ('\x2cdadb2c5c9316a9ea7150239d1fbf14460f35d3'), + ('\x2cdc8227aba05c10e6051280bb4351cefa7d317b'), + ('\x2cdf2c9da3e3429597edd57921011a9e8c0e1e37'), + ('\x2ce00f3cca426e13a74966b0f911a966db01356d'), + ('\x2ce01a497398cdd13a620a68a3d6ebd4f45386c3'), + ('\x2ce9ab2f1e2babd6e35c21336736dac00ee79dfa'), + ('\x2cfce236e6683ad45f51f9884dfb8778992d7cb9'), + ('\x2d04753d65208a09ae4ec218108cab076f83ed93'), + ('\x2d0ffcbf1c836036c3fe65a87dcc7451df53b07f'), + ('\x2d1084a73b1de8fdd29907539286de692f9f9c9f'), + ('\x2d1e33178bdef6a9b6abccd7a63606ef25c824cf'), + ('\x2d2137a1036075534056e560b6217d90f8e47f2d'), + ('\x2d23b2d502b801b60e5f2e2d24100b8817357623'), + ('\x2d29b24e3a134901ea29478088821c14b20231ac'), + ('\x2d2a01d33366ceaa9cdc05c552b642c34ac70240'), + ('\x2d2daf6325e4e3664eea591842b2177a1b482e3c'), + ('\x2d2ea7855f40b10553c38b21f9a9d0497d6c446e'), + ('\x2d2f350a2b806f474725c4a006c0684f1fae7193'), + ('\x2d31b0cd8e5523ec2c634d62aea6796a3fced174'), + ('\x2d32752ea99adbdd17bb0fc43b04dd8719e7467e'), + ('\x2d32a1d60052cf120ebb384ae3b814fdf8ff468e'), + ('\x2d338385c7611009b18957155735c15f49e64acb'), + ('\x2d37d0e8ddfe2bf35554b87bf3b5f26ad8704b76'), + ('\x2d38812dfe099d1689270a310324c8c707308793'), + ('\x2d38a5af20b99dbfddd14f4b3f85bb49cd599270'), + ('\x2d40aafe6e52f9ab9f0ec3517f5c173110ef550c'), + ('\x2d44569bf1f145019cc26599fc976c4f25dcd64c'), + ('\x2d4aad07ac373268566c2f781d37013b27bfba5f'), + ('\x2d4e47b6d842eca1ddbd334a7bfbb23828dccd57'), + ('\x2d4ef6f218185396de1f836344e1defe02439daa'), + ('\x2d58a72699fdb65ff7b04db6fe773f87f43e444e'), + ('\x2d58d8390c14b137694f0b1c3742b8c5f83d6282'), + ('\x2d592a9c4a5bde90a6fd94b42d586c63946e0c44'), + ('\x2d5c5942d13c842b2d2adf312c32f45cb54cfb36'), + ('\x2d5d53772dfea38f4f069b87a56460d701ad5599'), + ('\x2d627d9cf96066a44fb3b085548fe9af76bfd1a9'), + ('\x2d62e19e702d9f5c47bcd77da36c5bf7199ee258'), + ('\x2d64c9739b3f6d4a32481976d9cf84f9f9ffdcf2'), + ('\x2d66b6c994d85993de53c4fe2ac93b9ec8da6d14'), + ('\x2d6a21cd6830eee0b466803a0a5117063c5e1559'), + ('\x2d6c37e0d99a017a1bb4a7a97dea06348225c94d'), + ('\x2d6d9ac57992378287352336780907ce807cb146'), + ('\x2d6f5232de742ef5669e67584272e95ebd90fbcd'), + ('\x2d70cccaaedd0599249b4132dcfb271b302f2c0c'), + ('\x2d761b1679ecf95c3fc4d32828306d6fef833560'), + ('\x2d7c0433f109c1b8783f8f168bf419e71e903864'), + ('\x2d7f87bc61744ff73ee5f4fcd997c67f78e1533d'), + ('\x2d8932754a97a512483778ecbfbb6937633fad30'), + ('\x2d8f38cffc70e73cab0a8cf9b80a703fae8dc657'), + ('\x2d91413bd3baac7fe5d1e72f344d795058c2e595'), + ('\x2d97758c32a4c5d90a05f741dc7fbaee144afd39'), + ('\x2d9810754df08c2036dd7ba7a2f5d5293f3f2ae3'), + ('\x2d98ab53fcb3b0fa1ddcc1515c5d16196f54df19'), + ('\x2da0be9c49e19824565b47e026e18ad0dd75f0cf'), + ('\x2dab5650dd8df4b1c2c98abb945bedd8728d0d41'), + ('\x2db23e49dac135d210adcc4b0f5cd2cc5e8be21c'), + ('\x2db84e017029db6fddf248428817f81c3c844dda'), + ('\x2dba2f3f15ed33794fb1def4406d7cb7ffb0ddc2'), + ('\x2dc587552f4361b3670dc8a8c02cbbcf475a6df4'), + ('\x2deb06737899884468c86e79230ac93b6efca69c'), + ('\x2deb8424b7fb9d629caa9c85515cf7ae4969688f'), + ('\x2def19ac5ed291ab6bd4b523647cadb47df006ef'), + ('\x2defa994d086b423005d1ccaa61a96cf1ba2e6b1'), + ('\x2df2eb795e69494ca75aa4ae43122e83e68f962f'), + ('\x2df3e403514b9b333827e5ad6a06c1757d19faaa'), + ('\x2df6112ded286e6d1e9c8317a556cbb8c97da0a4'), + ('\x2e003f3bd78ff9cd73d7f20824234cf0b6d26461'), + ('\x2e0aaee2d1854f5c42ae7142f957efae5552aa67'), + ('\x2e101bd2902f42eadb658a072d3b8a91256766e3'), + ('\x2e1a4eb6c93ff8d846d26dfc4f7b02a38ae0ea10'), + ('\x2e21715c1d02715575ae4041224e1dbbd3d749be'), + ('\x2e233d2f6fb235b16b6a8ef016b2ba43a468f3b8'), + ('\x2e233e2fd990d26b62ebcaebfcf372a87fec310f'), + ('\x2e246e64f89f24155c04c8911e839860a976dc47'), + ('\x2e2bb83d27e92567bb6b224df8b711d4cc5f9ced'), + ('\x2e2d2ed191be6190043a88ff386d4e8ad581a01f'), + ('\x2e31d02424ed50b9e05c19b5d82500699a6edbb0'), + ('\x2e39f6636a7268dc6f988c7c539c51f0f3bdcbfb'), + ('\x2e3a88cbfb68d214b2557892add44b64e6e98ce8'), + ('\x2e3cb2b0d9b09f7a3a059a859cd7708637882bef'), + ('\x2e3ced4adad06cb3199ac62e52e5abfe16fd0644'), + ('\x2e3d0d5d11d8d8f8951741f89fea5d347d70afe5'), + ('\x2e462fdc317e1b3f40f16cf9591fb00cebef40cc'), + ('\x2e4ddff05aaa1c5b8a65484874c5a92402e0436b'), + ('\x2e54c3d8dfa7c14c7a84ce850018a8dd292cb07b'), + ('\x2e56761cdc93082a4b942e9e5b6d37a66651325a'), + ('\x2e5728a3bcff3902bef2a3e23c6ce5a5a2e773d6'), + ('\x2e57b9c3206b73068d6b0d08a401485ec14c1e9a'), + ('\x2e588e6d6c2015b2a45d819ecd69500c6b19f52e'), + ('\x2e5fcc70435eb11812ff13ceeb928b562fe93c95'), + ('\x2e620c00a92c253fc66bbb5a8b5827ea678ad3ef'), + ('\x2e65c72d258b6f42989eb30dd409c2a74c3a3a5f'), + ('\x2e6e2bdbf6112400230e057ad5f11b64e24bbb91'), + ('\x2e6efd61146878d1d56025cefbf6aa392a5f9814'), + ('\x2e76d97dbf8b3779ad07300bf1c8a96fefc4a665'), + ('\x2e7a29c8c2788d77912730dc12136a53c35e3979'), + ('\x2e7ca8ee4866fa2a4fc1f096abeddb3deccab43f'), + ('\x2e7d1a5992d0681884afc273f3409c7c429e0248'), + ('\x2e850350852cc5585dcfd810999c5f13aca88720'), + ('\x2e8ef113e6eb172e6a4534994c0a3096d2095024'), + ('\x2e934cb88ee137e61bd325fff3baed46bc577d9a'), + ('\x2e947896de517b11fa98a4d09e127800989ad564'), + ('\x2e9d609c6426ab1c5c5bfd45f900ccf4896131c6'), + ('\x2e9e28403c2440e3590384d972846ddc0a9cb1e6'), + ('\x2ea04024975b8768b33465d9c198fad9b1660b14'), + ('\x2eb4969708bd511c62f13d6fb90b6631866f4c89'), + ('\x2eb7fc8467d1dd74ed2d9d7cccab75f6a60f9d29'), + ('\x2ec4e1a1760de8bdb7f8435a18a1614b3b49db57'), + ('\x2ec55864b4b9ad43d0a96572ed8fb6df8ecb16c4'), + ('\x2ec7b3624abea48224e2fa833eacbe33450eaa59'), + ('\x2ecc9ee79789ca2be913fcb2c53bc47abecf816c'), + ('\x2ed6888d699dee9a18e7aa866c45220d322f6e3e'), + ('\x2ed849e0792b36757c7c158efe41616271fb7e02'), + ('\x2edf2afc2ca94fa5cf5767b43a51f5761f6c1f4e'), + ('\x2ee217fed2e8ae1f289b17b7167945115ce97263'), + ('\x2ee2c4002b22c3babe6283aa515908987f2e8bb7'), + ('\x2ee406e1fff50a239cc672b7299e3e9f54e6cc25'), + ('\x2eec86b73befe0c0049efd0d81827d1afd7fbd35'), + ('\x2ef158492d6e79c1ca6a865527ce352f66362efd'), + ('\x2ef3d72a20a1181adb2c41b2f23a616fad648945'), + ('\x2ef495db1679e4718bcd2c54a759fc92ba276e0f'), + ('\x2ef75fa595a7885848ab2e8b280685e4155efb8a'), + ('\x2ef7c59eca7e8b6ec3ab85472ba7c838bdc60151'), + ('\x2f070eda379409fd26ec688ba32cddee9845dca6'), + ('\x2f0ba3934d87286898f4fde99666599e7c8fff39'), + ('\x2f0c5efe89e05481b6d7c3c0170e1b19afa1b983'), + ('\x2f0ff0a3a9c84ff05a145841fbbbe8ab7cf92193'), + ('\x2f19c3449162e2deb28857c76d0d1145429e23f2'), + ('\x2f1a38570ddba6ce0bbe8d478198cf597cacd08a'), + ('\x2f1c324e679897d9d8351873aafdd0db23d5db67'), + ('\x2f2c56655d0a97be22e6d55f41f3deea15ebdcc3'), + ('\x2f2e52472e2a316db5087a2281dc3e38d5aea756'), + ('\x2f366b976e13a1c2a0a64a17e47776551e73879e'), + ('\x2f3aeea61544e45149a729945eea9c8e40e3c71e'), + ('\x2f3d17b16530933569936a1e2bf15637154004ac'), + ('\x2f3dd6ad16b1ced7a19402499575ac481af098ef'), + ('\x2f445772691586fb7a1bc71b4a30ec7b22e45680'), + ('\x2f4b1b4b44e96aaab0ad28ce225f7e9a2b3ef050'), + ('\x2f4e007ccb3f9d4031b75b8cefd29432865ce840'), + ('\x2f4e5b8bc8c7d72e196d6decc00bb6b95302a1f5'), + ('\x2f4f67d8647d2a3fba1010a4d1c383d9f9afbcf8'), + ('\x2f4ff4b4040a2244adc5a796057883906683b317'), + ('\x2f51caa0c0534d7fa4207280f0cdb69b1d912cad'), + ('\x2f5330cbe1169264dd35500be591ee422a7f8ce0'), + ('\x2f5333f30234e76bf5aed08fb6f9f89c74c43167'), + ('\x2f59717ff3fb51e9d584f32e8125ea96cdb17dc2'), + ('\x2f5a36f80e0f0653ae0fd45dbbee24deb03a5024'), + ('\x2f60596e325eee4c11e8ecc902c3574a55e9ab1b'), + ('\x2f62af2c1b8003c71a3421b1e8d66849f772350b'), + ('\x2f6a119485cf837fccf6ceb10c55c85dd08a8b76'), + ('\x2f6c12aaf529e2af3340886dc806741978942507'), + ('\x2f74281bf6674046e0a183090ff1f816c67e7bae'), + ('\x2f799557ef9ab163cd6a0f63085a101d44e3745b'), + ('\x2f7a2c66f69e08c0940598630a829ca9b6212828'), + ('\x2f7b51fe97b333b687d69300ca112e506a538d9a'), + ('\x2f7c55ed89c1473c5492c719cc3b849107f62dd0'), + ('\x2f80ca4a58b3cf714d1df7c80e66cb49b483d9da'), + ('\x2f8213b895127f867fdbaa66bd20929e5d9efdce'), + ('\x2f840b7514d8e07ed465210e396ade1e76dcb675'), + ('\x2f8abcd90689660572d994d88e78c2f5bccb014f'), + ('\x2f90af7352be00fe789d676ca4094f2ea754147c'), + ('\x2f92df944cefedea4388b5f9e5da0cce38791f20'), + ('\x2f942719062e9877534235484c53acfb5493bf2b'), + ('\x2f9a6017fb52ec04e313e195532065ed38a41c4b'), + ('\x2f9c5483d32e1218a022694799673235125819e7'), + ('\x2f9fb74ee11bad7e58d7670386c91bd3492ada2f'), + ('\x2fa2fcf0e2a1b514872e90965d618cac90da914a'), + ('\x2fa84672bff5a7bf706869969b4ba8aa07245a5c'), + ('\x2faba2ff25c16c218a1ff81f38a47c2426b1a0a5'), + ('\x2fb3d4b7ae0f7c7d322e7f56de5003927982acb4'), + ('\x2fb457bed72eaf1a4ac41fdeb0fba66667d2566c'), + ('\x2fb7b4e07eaae44ae4586ae3798fcd1bc49e0ff6'), + ('\x2fbeff7b3c896e8abc8915ef0d0684369f4fdb78'), + ('\x2fbfe2408e8715ff350640f568e6e9289748ecb1'), + ('\x2fc776b9cc3e75a71e2e8f317643bc26bb960b18'), + ('\x2fcd589b92948f9a501e9d7db51a87d6f74a82a0'), + ('\x2fcf036d4da6d60d3180aee07c18da3114e41ee1'), + ('\x2fd5c26cccd043a86da3f610e26935e3153058ab'), + ('\x2fd722f1df1358b6329cc193b63f876e38758267'), + ('\x2fd914f440d70558f6957403a87c767859e004f2'), + ('\x2fdadc270ba0b7a8e390372c6d5e18d576f2e521'), + ('\x2fdc893de12c9a86c31532e6fb26a7d644e8c180'), + ('\x2fdc8f9bb3f5b2f0b57c424125c80ecf4087bf7d'), + ('\x2fdf347c4102ed63255b93248cad412e87bbe26c'), + ('\x2fe32718647f0bce2b2320a796da45e226f76cd7'), + ('\x2fe53d170aa61aa8ec65c21313200e49b81a0847'), + ('\x2fe7dc48e73b2e22d75e225a5e4549b6b6f95033'), + ('\x2febf5f22dacccdf0ccab98f7bb675d16f21781b'), + ('\x2fed96dcaf193cd944cce00ee9218f4f16c9cfe4'), + ('\x2fede68225560d46a526f50668d9aa1b2067f498'), + ('\x2fef129d9d45c000166f87dc8ca5d5c96bcd1ce1'), + ('\x2ff0b8c69fd90629da5d2981b9913d886b477ab1'), + ('\x2ff2279a63662f02aebc6efd8451463931c0158f'), + ('\x2ff81005403925a63944a9dc07a619d4dd8595c7'), + ('\x2ffc1a22f5faf858fb66abe2cc90445f30c57992'), + ('\x2ffed4e5d112b369af67c0a666841cdece3affcc'), + ('\x3008b9636ad6feaa65a36f1ab8f6eb975d655f11'), + ('\x3009bdd29f4e9e2a95899cfc1da528f71ab207c5'), + ('\x300aa6468e431de986611edf6816c6919a5c8c4a'), + ('\x300ad00c0d86824f27ca0a7505686898a7c3c7d1'), + ('\x301434928a5b1aae37a014846f06de706680b5bf'), + ('\x301ae804cb4573ce83ac0d9e2632980370daad92'), + ('\x3020d979309d77fd83071a20a86316f38ee8386e'), + ('\x30215ee65d4a26f8f6aa89075554453443e7e10c'), + ('\x3035a83d3ccd895aa03b041152875d067672ccab'), + ('\x303af442e2c697da7072ce944f062e320ac6a3ea'), + ('\x303c924829d4f8212575b8c6aef7c2b4d0c3ce47'), + ('\x30404ce4c54634bf430d2d154c10c45b8b1eebc1'), + ('\x304868d4b7eecb97510f848503b6e9fd98cf604a'), + ('\x30496b0d9edad0fce9e64914cf18b6766daca03b'), + ('\x304a2ade65fe9b1e4ced31c7afede57cc1f13ddc'), + ('\x30540488eef19f3d54654f74e6fe5f34a7c61618'), + ('\x30541220df76080e8ef42dd770c1e21cc5247cb2'), + ('\x3057538c6aa657854b976171369f3068a0608347'), + ('\x3064ae201dddf580457608d2c28eab7b5aa24fac'), + ('\x306dcde78afadc54dc7c2b7e7e27d2054fc1706b'), + ('\x307096bea7ebaf897e0c33e24990469df89daa06'), + ('\x3071bc0f5e14274e8bb0316fcee3a19307a9d5a4'), + ('\x3074a5678f2baa8e1e750e2e9cb84e887c6ea444'), + ('\x307d9229a7798494cd46b715d5377e1289ab82dd'), + ('\x3081b3652cce2e9afec7e8ea1b0f98a73cdd35d7'), + ('\x30822ac6442a9d5e6a9eca4da50cc83c1bd77de4'), + ('\x308ab9ed4333136d08389dfe5763f43c48cf58e7'), + ('\x308b7c3d0a9058dc36944cdc7a12ed8c7c63d9ac'), + ('\x308e234caaa2b0aee6f8518803a69f58a6ea8fd1'), + ('\x3090a2141ee5df3b608ef427f6742649d6b155c9'), + ('\x3091b24a5354ccf8899204bd4e386b6d3e1e0986'), + ('\x3092bf0d70499c1fb32e6e5ce7ba59a545268d0a'), + ('\x30a2c6b912b254096e40c495ae50263b687cfd8a'), + ('\x30ad7e5107506a0e02c3959d261b8437f7bb6fa3'), + ('\x30adb15bf9e9f6d5f60e2d30427bc7a0d5363c68'), + ('\x30b26eb15f4647bc3a5fa2e326223a141d876d37'), + ('\x30baf53752191300ceb5a8ca3eb1daf28c178080'), + ('\x30bbd431c3346496d77f6485227ec48815cc5a88'), + ('\x30bcdeed6c9a10bbf145b2848a5a5b39cf1da27c'), + ('\x30c2ed91987ac50b4e73de472e364ed27930ee4c'), + ('\x30c520d83cded9110f7c9601810f0cf2b5b0d4c4'), + ('\x30c64e01df161f22674cba317162641194ad1838'), + ('\x30c656e70cd9e466d514666e3871f06db5110822'), + ('\x30cc95c26b63ab6944092d2328121e296400f5d4'), + ('\x30d0b4e8e35567aa6b672d67d411e087709ce949'), + ('\x30d163bc6ce58444386700eca98bfd77d4a779ee'), + ('\x30d2ae159b4e380aea88fbf0356b9cfce2c57281'), + ('\x30d39657eff2afe49a364ca607214e847f59d80b'), + ('\x30debbbebc5916e2d319e75432a102f9adaef321'), + ('\x30df35765958952906362121f1bb11f672edbaf4'), + ('\x30e2f141c31b991852c6110f3abe7ba06666b845'), + ('\x30e4a7454c1bf842229aac5ededf5712f5020309'), + ('\x30e6e52316f7e6e934122a11048ca9e11cbad029'), + ('\x30e715fc7607b0a9ff398f1fc13a9a7aeaa0fc74'), + ('\x30e75c2aa42d5560a8db9bc357e0425bc0219e7a'), + ('\x30ecf956468f4f601961617f0986b458bde9b93d'), + ('\x30f41fbef6c68676754a74e925c954244a743a9e'), + ('\x30f8eb3c1be63780a111ed4ba53556a43b2576d2'), + ('\x3102cc4f58851b1ce67ad8e5cfbaacb57f58a07d'), + ('\x31059625faa60959a7003aaa617d31ca98b39915'), + ('\x310b79ed0b00031ba2fe402d857636bb0527a117'), + ('\x310f5afa29de155218958b09fb33271c634adc8e'), + ('\x3111d0edfb1f65aa21066110660870cb7a8362ea'), + ('\x3118ebf5b06a7c497df468476115fb8ba91f6d17'), + ('\x3122a22e57a93ad87f72c9ca6e3ab5e65d21060f'), + ('\x3124c4eee76cfc4ab1cb3a15156bade6c60765a2'), + ('\x312c59f077a6810df4f08ef1c61e24317aac4ca1'), + ('\x3133f084aa6ac8508401494590bafedd74ffe900'), + ('\x3138d3ed9447d8a8966e126caf6756ae9599b695'), + ('\x313bc1accd8f7dfbbf9c09925e8e5130c9b30014'), + ('\x313c54a799507f00d47aa7ad156e53b27698f86c'), + ('\x314116d14cd354db65c60a4d32ef7bbf66fcd07d'), + ('\x31456833e65942702c6798348e1ebcb85eabd4ea'), + ('\x3147f5f97c325ba260d01ad804ca33aa01134115'), + ('\x314bcc319652cf815c1fb9c1c7fd9c100204e351'), + ('\x314fa9750662d5c8490772037f3c886d404be87c'), + ('\x3151675391f1d20c7ed42b2e1125289ae4541713'), + ('\x3152058a3d4ea247f6aa242e7754827f226ff21a'), + ('\x3165bfb3311932fe98363ec1123835bf20ca4493'), + ('\x3166d5d0397bb4fbca9259c2c68b46465b1befac'), + ('\x316b7f75d038436d68d4fab57c1c0d3c803d9236'), + ('\x3170c36646046f12d9746edbee2691fdfdf27719'), + ('\x3172153aa532a5d652b53f7676339f4d032a3103'), + ('\x31728856f2dc86a49ae8413ddd229e2148c7dc1c'), + ('\x3174b89f828e80e6365deff48e68f115167357b9'), + ('\x3176b505d5d7a2080dd6b5bed0701db7c720765d'), + ('\x317f545a1cef691e178f6ce3a72e141a2c32d351'), + ('\x317fe59f73bfca9a5d99e754cd8256d521676370'), + ('\x318142665c12891e479216376b5300a87173c3fb'), + ('\x318309fa80bba54ce4ec15336ccec4f6939a48bb'), + ('\x31883a0ad82786a75b3fc9379060b4755cc2c540'), + ('\x31909098262a32109b26434aa8d866aa4786b815'), + ('\x319935fb1d7927da3ffe1e0897807fdc8207b8d5'), + ('\x319ad4ce0eb2c63d5bc4a599869afedf4be7aef6'), + ('\x319cb38da04c13afbb17255201c33d0124dcef8b'), + ('\x319e5996d4ce687446f14cad663c9bda2d592c5b'), + ('\x319f8903f49f14e7f5c176c4821c204224f72235'), + ('\x31a040b058103ef26d5ad4f337acf94f7cc7d7d9'), + ('\x31a331f4253c39d031e1a435016282c7edff830e'), + ('\x31a8b85eb6ece4da5cd45d4de9c6e13af8b60e28'), + ('\x31a8ba7d11dd45ec96f241ab1558df3d0d8fa681'), + ('\x31abe5cac0c9436212983dc8a252f93aed32de93'), + ('\x31b0246a0f8dd692f0c404f396cf120d03735011'), + ('\x31b3b19555ff3b4f3acaafbd954939cfc84757c2'), + ('\x31b582455590ac08ea6d3333d56f0b2deefc9457'), + ('\x31b90aee632eb1a6fd390261ab30b2dcc88a40bc'), + ('\x31bb3e112d3861d8f09e29d4e2373dffe4961879'), + ('\x31bfbb0852591ccc490b1bbede092748c1bdbcd4'), + ('\x31c18bde1b8d57c1cbc2fd3cac0298624f8823db'), + ('\x31cedd8e0d56ec3f09a88d2d60dc5c12fff4a9fd'), + ('\x31d1597272863c9d7935c599c9466e4358aefd62'), + ('\x31d7050f9a4199b3064ff957fdfc8240eb4cb0f7'), + ('\x31dbf5c4725236f7bbec343a16625c96d122e9d7'), + ('\x31e05f619cdb7dbacb9cf76d8bad64db4a0141bd'), + ('\x31e7a5b3e3b54cd67bc2895a6f66f4fae82b2a39'), + ('\x31f9dbbdb8a60903d8d6936c38bd7033ccb516f5'), + ('\x31fe5d62bb67c7e7c0b748f1f275df4030a2a01b'), + ('\x320c019654a2a3d7f75a2528089efdec6e81d915'), + ('\x320cc60cc9975e28d10c9b24fb771944b2b3969f'), + ('\x320cc717627099ce16941326b235fea409e9199d'), + ('\x32101f0a49abb6393066b840b6b4ffa773f12410'), + ('\x321250dad2f145bd780fb58da60a4d9d42fb79f4'), + ('\x321290d5aa4d62f4783a095189e8a5a093cfbf9b'), + ('\x32187a68ffa8d799ff34eacf6ef456f66d10c1ed'), + ('\x3227d4e749c82429987ace802be2dcefb9f9e89b'), + ('\x322f924febbc4facff05cd5b3b836a8c22b026b3'), + ('\x323417a14ccaf3696a6faebfbf2279520ed6a67f'), + ('\x3235c2d2ed6ebc604faf0426dfd9eda21a680bc3'), + ('\x3236ca5554ce2c024f00df000b06be9615134c1a'), + ('\x323e3ef1889ade6cb8b8f320ce2641d03f947fd8'), + ('\x3241df831c04bbb972d9542bd25e87d0105ce76f'), + ('\x324724505bd9aeb7ab6dcd079b63b35369d8f02c'), + ('\x324dfca9d08bd8edc6a008c2a22ec41c64039643'), + ('\x324ed371ed3a43324bab57700a36e238f9dd2414'), + ('\x32584f85584b65c16dea86316c3f14cd5dfe7826'), + ('\x325987fb9ca76d05320e87f1f8cc67e2dfd1e953'), + ('\x32602db303b1f3dd7963a5c122c49a4ec65d83d4'), + ('\x3269ff5bf7a7d839b6889cfb3b12308946ff428d'), + ('\x326d427b07b6aabbbe78ac512fb5b38a94c7cf8a'), + ('\x326e9bbf066a3e9372b364460f914053f0da964e'), + ('\x3271d786f7dd6bc16c371b7cb167a572f13af64f'), + ('\x3276ec6ebf50236944d8e44485b7f5c478d4cd91'), + ('\x327ad637f4fafeec8625dad4b7b81f54b722bffc'), + ('\x327bfa4ba26c2cd34175afc7114263b0015b3dfe'), + ('\x3288d37ee8099ac202ae991a429e6654489a54d5'), + ('\x328b58248a5220e40b6e8591436e537a86442341'), + ('\x3293e6026123ee988f19ed6b1203d8215b7e8010'), + ('\x32952f3cb5b91866030aa9e687b3356786057914'), + ('\x329850b9327d525e5f59a3c9f09cfee3f1b6c599'), + ('\x32a0b5aa132a5813981fc786e9257f4b5905b7c3'), + ('\x32a40d5d5a79216c923ec6b963eeececca38f4bb'), + ('\x32a7e621ade439ec228c9519f7a67fa5a47f4bf1'), + ('\x32a8c0beade4ffe9cfc6766ac396c834ec6fc256'), + ('\x32ae0f1aadb1410ebb889635ab3988b610f23812'), + ('\x32aeec0c51379a49371356684a5b28875ef154df'), + ('\x32b1893cbec28178b2bd898374627ef2c2d929d0'), + ('\x32c1a8fb639177548b70f8fe0ca7113fa2e661c0'), + ('\x32cb2a2c1a6601c5f0e5a28e8effbac4aca39e80'), + ('\x32cedf5bfa52fdc23be1e009459f99d950202c4b'), + ('\x32d30b4ca3a1128e68dede96677adb1ae17198da'), + ('\x32d689a87321fd0db38e401b50c94eb64a4124f4'), + ('\x32d6f9876225c9f94f50cf0cc6f2a6b06f95915b'), + ('\x32ec84d0620a216bd2301dbb3563add8acd9ddf8'), + ('\x32edafe4eeae60b8ee474128ed672ac2e24d7166'), + ('\x32f59c668e2b055b4d17adb7ec849c407c863f08'), + ('\x32f718c6214e26fa519354ad903e96f88c704bea'), + ('\x32f827643627c81e8b2e6e4baf13421ef251fbd1'), + ('\x32fb62f1473d81b694777daa8f2b77bff09ad1a2'), + ('\x32fc48a89da3c864f75eabd564761c50941fc45a'), + ('\x3302dc8b4564d16f65a71dae34b42a5c4190aaf4'), + ('\x3303204be529813ad0d2c361ee02865d3dc1cb9c'), + ('\x3304f9f99124d5f74a3c6fddb78c61227293ce91'), + ('\x330697a2925d68e07a300f12c8a222ae017c4b93'), + ('\x330b150de1e00497eec28ca0ed97fd6b6750ec2c'), + ('\x330df771849ed0141b9d39fb45225ae2a6acd7f0'), + ('\x331026488f33f0f33d52427eb623ab74ff9a8955'), + ('\x33159ea759a8ba71d59ffe9c4eadeda9d1f2e10a'), + ('\x3322188a3e4944ef1eca69c430223dafd03667c1'), + ('\x332aaf4d9c3638143e1206bf11d83b54bf3e17d4'), + ('\x3336846c99aadbc62431283c33ed87ebac597f2e'), + ('\x333ac0677687baab0dec02d3299e670cd12a5a19'), + ('\x3345e1c398f36114b17cfc1fa3f80c606c1b1887'), + ('\x334a4eb37764796977277f7bb253b92337231238'), + ('\x334b1f60211513bb22c9c7401f4a1cac33a3b6f8'), + ('\x334e795a94f7ed2e93d50ddb9baaa9038efda125'), + ('\x3350949408970c1767db5da664d5a2aef1ee7ec2'), + ('\x3350bfaa79bb5bbedc760cf96a12149460f86340'), + ('\x3355e006be3653abd716cc53538f8fa6d9f7213b'), + ('\x3356ecf042d5e5f902ad8165f2891a5ecd959c29'), + ('\x33641b81ddc2640f0ea1d5786d875780ed8e13bb'), + ('\x33734fd84df4643bbaae47a17f883c4a365f35e4'), + ('\x33756ebe4181630575b2e266531257f5dcf06114'), + ('\x33788f896daf197e5f3927b31e73fd0bf6c81907'), + ('\x3378b2029337af76201fc1406f6b220f7a23713f'), + ('\x337e71f1bd62d070fd4c23b82f1bd3ff1e049503'), + ('\x3386d100f09cdc2ea7fb1d7babf515abfd74bcf3'), + ('\x339945012772baac40ef42cb44d712fef69941cd'), + ('\x33a9669cbd1894b9750bdead25445756c9ba369f'), + ('\x33a9da7d8760287d254bc28afa55f92413f1605d'), + ('\x33ad9f770a8c0e56cc8ce565952237d3432eb94f'), + ('\x33aff4bca06562ae7490e91161079165ffec8bfd'), + ('\x33b67895bd57bc4598c592e57cee31967942c746'), + ('\x33b826ced7775161396b0a20bf36ec2a5e3495f2'), + ('\x33b9d456ef6b699d4131b2a37a0cf176128bc823'), + ('\x33bd21cd006bb76d25f51b7ae8f035f778741472'), + ('\x33bef7af3eeeeb6d35d6bba1c776693249a7565f'), + ('\x33c3251258ee21815279d6f9d2afaa0294c15c49'), + ('\x33c652d5c78dd0eb358807c62f7e13110fa38e06'), + ('\x33c86649d949c81f788bc0a4626ee57ba63f0880'), + ('\x33c93ef90f792871a599e8fdf455eff5f186941a'), + ('\x33cc10530e8ce30b1e335d16f01aed4bfcd0f801'), + ('\x33ccbac3aa7a98fd3c40c3b8b9d486a4f2f857e8'), + ('\x33d19f6bb1384022113537b42e9d70dc6c8d0ef4'), + ('\x33d254d4782d8de966b462fced3972f123fa4f61'), + ('\x33df41cab3f88b3aa1b7f3d592635e59830cc314'), + ('\x33e0a0d1f9576293e87cd24283d3c76251ee4143'), + ('\x33e3184f19ffb34846869553c5e3500c1fc46b54'), + ('\x33e3e2bc573d22b4a63c1a37eef87eca0d15ca8b'), + ('\x33e9239f37332bb5299027160aceae02aacea229'), + ('\x33ec883cc5e34bb233f3392008546f56fdd2b6da'), + ('\x33ecc47151342b8b155a5b97a9cf3238611e281c'), + ('\x33f67f01a74e353405c8a3ca5ed33d3ef1c854a1'), + ('\x33f9c9920380b7f74351e2b28ce9c04a750939ef'), + ('\x34030d09b62b1c370a1f84916b47d74a9e40acac'), + ('\x34070d41af0d422e3df1482905cadbfc015c24fe'), + ('\x340d1e5be9d31eafe3d42e72538f9285dcdadfae'), + ('\x340d84d049d46a32da62e481b1ddbeb08e0dca03'), + ('\x3420f81560e05ab830201d94f2913bc114d21a48'), + ('\x3427cdbc9b20d89c2f247262500ff3bd6c914703'), + ('\x342c999c56a7e3f7a78a98c6a3b082cdde4ca992'), + ('\x3430649d6855c93a248cb516b5a36b7df8f0329c'), + ('\x3430ddc84f08c814214e11f6c5f727afb10fad4e'), + ('\x3432761a206bf55fe3914fbf642187d009376842'), + ('\x343492db48552ce1783d0f149ebc0037ca2e1d35'), + ('\x343ab34e929d3168e39ee70ac5e30d0d4d4e2606'), + ('\x3442f11cfbe0fcce6d0e91ee668383d3a8ac43ec'), + ('\x344d3f09a997eeea43d11f9dd533274ceabb3cd8'), + ('\x34514d8d68d60f9ec28edcc37f70182510334362'), + ('\x3454d64fc2544af0f83a36b18be8bec34234a606'), + ('\x345fc7d05e520f6cb303f6624cb70f33d70e9fc1'), + ('\x3460eb3ef6d30f34c81aac347d98e7bac1e4e810'), + ('\x346bc988749195745884cb6bf1355f602dfc4dec'), + ('\x347921d73e731fb147d76742ac267890d15463f1'), + ('\x3479c8e602f28bf3213a052ee4a5ab99372d63a5'), + ('\x3484ee282fadd0d10eeb6442fc770b2a0f31504f'), + ('\x34860f22331f2f323dfd231d4222a030ac17be61'), + ('\x3492a5b9699bfe1bc6efbbc003a02ecf39e9bb33'), + ('\x34941fe71a406640121b0cdb2c3718a018a19036'), + ('\x3495f410e681883d4316fd07f3ef4111b4508583'), + ('\x3498a80cde82e26fb3db92ab180ce863c6cb11e9'), + ('\x349ddc5455dc447dc8b9d4e64186ed19ac28e80e'), + ('\x34a47c415784e86321fedab961ffdb3c0fac413c'), + ('\x34a6a378ee9cec153cd075c06a103b02a385256c'), + ('\x34ae60fc8d8608879fd748ac4284ee346f0a2136'), + ('\x34b01fd8f2a4d88dc8961ce28bd4aa95f151c17a'), + ('\x34bf02ab5e53b1b6f7d19e4dd9e5ed7fae5fbefa'), + ('\x34c578146ad093508bca213645bbf5cf4f46a4d8'), + ('\x34c788c8a8628d2c37626691cd606ac499b65a98'), + ('\x34cacd0c6f304a0fd9449bb9381f7daff37cc3dc'), + ('\x34d69930e611a71296b824a36763993dd2846bb4'), + ('\x34dda1bb3b066605402c692a69b8a76bedee9240'), + ('\x34df8ecb53e0a2564602bdb05e1260f29e9ab883'), + ('\x34e1b549646ec85dc5c465a742fda46605b69930'), + ('\x34e1f1fd02828b103f716f623c83f5399bc1ce75'), + ('\x34e84a48ba198960ce8a4a353578e4f3c60ece5b'), + ('\x34ecde62e71e4b680a90e768358a6901c900c9ae'), + ('\x34ee54c5ba630a880df8619f10bc4063268f8240'), + ('\x34f1352de22d3241478be4cdb84e571f3a0b7410'), + ('\x34f5f19b9b779ba27df0113cf04c32a3fdbcf4af'), + ('\x34f6b2878c34f3fc3267b5522ef295b2ece43f80'), + ('\x34f797b735d5e71b129ea0bd347d9823698c762a'), + ('\x34fca2378bf2c992254b44183623fde295072f77'), + ('\x35004190440c71b7a67d7a9e29dd6f08f7ebc0be'), + ('\x3506165d4ec23327c390f850cf59deafbb1a220d'), + ('\x35064695561fffc04eb18ca78d2a04cd02596e99'), + ('\x3511c2cde836272c7aa1ce333ace5af4984ad6dc'), + ('\x3516443186daa1a04a269dd86f9cd8dac5ae906d'), + ('\x3519ca353cfe7d049e705050a5890f4c0f822663'), + ('\x352693e27774d9ad87f4367437c342888ecf1142'), + ('\x352c1a03f427375493297a7f03836c48d51f11fe'), + ('\x352db77992c207d852152523f7b4f90d2b88ed07'), + ('\x3533e97856bf78742323c053a1d138dfdd2a4a0a'), + ('\x353e2e93dd56310bd6a623f1c909f17b718fb9f6'), + ('\x354128468aec94d7db6847b1b1a0363ee54bb621'), + ('\x3547f0a816cb0353927cc9e9bf41377dd3d9f45a'), + ('\x354890efebb6acb58e95469032690d508f9fdc9d'), + ('\x35489c46b4fb6fe24645858e5ab4dc1858f85c74'), + ('\x354ee25801e7bd28051bfb0729b1786b3eb2e14a'), + ('\x354f8df3b4ab97c13726fae0f18907b5fc4eaedb'), + ('\x35549423a1561e6efd4d6b33e39b2d0ed316c5c9'), + ('\x3555e2612abc93448cf17b0140180ccf1d8b0998'), + ('\x355f3ceff05e7c82d8d4ea9fcc103f78fd417b3c'), + ('\x3562592ca9cc9febad038e2e04476841f6a7b620'), + ('\x356600de44067b4272a5ad1b641ddd2c2ce26521'), + ('\x356ab30ea2172a12ad685c4b4f0f568226de4e66'), + ('\x356b8ab24f7a4e1fa4b0f78de90ed23c3e2b04c1'), + ('\x356bc3cfb9893a64ad5341f6bc804b47eb032e7c'), + ('\x35765e1c53584235dd7f8210ff61e2e181fd559f'), + ('\x35770fe4cc5f9ca842d96f06ffd9832754c41fa4'), + ('\x3579552dd36ff4e99b317a8eb3b8c80c618cd672'), + ('\x3586d934f12c5f36a6ff1f1e251b342220cab277'), + ('\x3586e80a8e15d06b8993322eb7c98faa91df613c'), + ('\x358a634a5fc8cf6ffb800ebe5e4427a33f7bfb7c'), + ('\x358b6f9d9344a72e9a545bbc66e5c31e05ee7c07'), + ('\x358bed7d09c22e3f1ca6bd6046ddd8c28aee1f4c'), + ('\x358c1d4d35e761e744e0ed336c14d55d4f058b33'), + ('\x359242ad20e2fb31c3c6753f9775a078382fc087'), + ('\x3593a4ff18d162c8553dba1e43de55b22b1fedf0'), + ('\x3594c4a2a9a345c9bd0137128e89503a9c0f0612'), + ('\x35990774691138ff95e780c2e011f6ff00fd1e12'), + ('\x359f57cb6c419d330312e30a4c0bbcf673154a7a'), + ('\x35acda2fa1196aad98c2adf4378a7611dd713aa3'), + ('\x35ae3571f470f6bc71e1ac16bdc28d11c689a47d'), + ('\x35b5e9017eea9424aa38503876893a8e3eb77169'), + ('\x35bef38902cb0eae90b624dd3c716c7c5405c73b'), + ('\x35c162c0913ad32cf0db80a1d2b952cb68e65f8e'), + ('\x35c603c8c57d61a80449bb3fd207b84a51aa0878'), + ('\x35c888d9bddfd5d84f6a108799708167a8a6ec14'), + ('\x35c970c2b57ceb26aa73f4a503124b8ada4ad14d'), + ('\x35dc7c1c914666e66e85ac312dcd42d7309a02d8'), + ('\x35dcb26f00b718f0b09a90dd629522fe99900e3f'), + ('\x35e00725075098593a6236311bc421d0fe0bda78'), + ('\x35e96f4102434b00227c7f24410eaafc9da9ad17'), + ('\x35f261cfe722b6ef0e70ac0ed09d72c993979174'), + ('\x35fb12ec0381f1f432b5de50671a84dcf9059f17'), + ('\x360859a6dbc470f60c76f3ea7edd56ce13988f7b'), + ('\x360bc7402968375926cb8e2cf046ceaace2f1f6e'), + ('\x360c23662ceaf22d35e89fffaf2c3ab331fc9c44'), + ('\x361642ee045e509127cb76d6b76ccc5fce2081f4'), + ('\x362367d857b65d768b19ce3917b5c289b6b90163'), + ('\x3627485af1e594bc02476d3e32c3bdc75b6b883e'), + ('\x362c8e32a39eea342860fee4dda4c9ad47c2868e'), + ('\x3631aa0e65f7356b1b00fbf4f6b554b37d29c516'), + ('\x36370da3d4b781334188caf66d949e113e2ca155'), + ('\x363a6a79ee34e63a6dad2b979646a033d34feff4'), + ('\x363b6c69102f5bc29a325b57f7148577f5b1069e'), + ('\x363e3c27474b0fba6463f4add16d874a4edabd64'), + ('\x363f6fc9e0accd2db6212608ae89baa8db2de882'), + ('\x364e3a3286f29e9a7b3cecc4d0e5ecd7e4dd4c70'), + ('\x36502ef1ab3433a14cb856efad83ea39c6aaa7dc'), + ('\x36524cf4edbe0b6bf41923b2a22ad034fdab6729'), + ('\x36530098ac8907ce6cd5804864bdafd5f328409c'), + ('\x36546aa1e73db90fc9a2862605cddc5d4acc9e7d'), + ('\x3657e334460795dff4266bf5a8cb37e571e7516c'), + ('\x3660681d398100fd0e2dcffa089570eea893629d'), + ('\x3660d9dd58e91e8172d95f74508dce6f254715f1'), + ('\x3666acc420a8078f5edc03c230d5350d14c84050'), + ('\x366778b7412deeb84e2ff6238fa222ca8495ca93'), + ('\x3667bf918d4b91b95bafd46b0f75280252c0576a'), + ('\x36682927a2ff3ce191a2227e59f1fc2d0c3cb2e6'), + ('\x366a6c1ba53087a39a10da35379d030d47e6c979'), + ('\x367905ebcc82c815131f0a80705e63a7f1cb2188'), + ('\x3681293ba63127fc128bc4ea03b03b3cfbd1e207'), + ('\x3681a281820e0348e0f8c2a1ce4b4c3046ef49dd'), + ('\x368265614efa13d658882f47df672c65ea8ffa37'), + ('\x3682a11d4512a3c1782bda8e4cf490301a473fdd'), + ('\x36858e0f83342f9b8345f5468a0f611466501e70'), + ('\x368e0182359268fe3f7d4c6bf1d6e4bf9a03b490'), + ('\x3696770a96d2d4bf55961666a17d1940e3c096aa'), + ('\x3699928247fd5e589134b41beb6475c5859ea9f1'), + ('\x369b28a01d3d399bb2219c2c589a0919d26c1ee2'), + ('\x369db51a9c3c371907509440802a9124b022b466'), + ('\x36a53dad8eb71f4de53a79e1ef8e503e696c362b'), + ('\x36aecad3ccb30f729d058282882f9eed06623e64'), + ('\x36b1cbe6a683f5f4e5851b391ef4b108826fa08e'), + ('\x36b3083e03a995ef0a425f1cbe2031209b8f6b41'), + ('\x36b3a1aa55419b5d52b618fedcef8f21f6727ca4'), + ('\x36b5da7a6d590ccab1904ccb1b3ee31b360f3085'), + ('\x36b60ef67b435b0b7b4b874d16ddd345c56f95e2'), + ('\x36c34b9faa8bcf8699b1e73380a9a1b1835e8fee'), + ('\x36c54efb986fd2ae94d4ea7e5a48e149a5469205'), + ('\x36c99009475b0746901454802889ed40a504c6f8'), + ('\x36cfddcc0775084eac0ae4a5f5ef38bd705ec05a'), + ('\x36d0740c1b3536f7a9428fe9b4efaa6aad5d6dc1'), + ('\x36d098b24297f9f13ae98f630425f82a98d4c285'), + ('\x36d1afe75b5bc60bd219692f0dd2ddfb544cd6fa'), + ('\x36d461608057f3eebe76a34f0117ed6435bf9a5e'), + ('\x36d5e4a68fb508311602327f7e956bf491538dc6'), + ('\x36dc2fb86b27d3fcc9497c778f8e769e8380582e'), + ('\x36dc95fab70f314287fd9543cbffc418622f25a0'), + ('\x36e3adba31eaa46fe0164bedc012d3e69fe6c8ff'), + ('\x36e92d1211c39aec4d21f301a6b1c7078b33c527'), + ('\x36ef92583a7a74871367cd868d9e550e2ee6b6a7'), + ('\x36efa9a1cc90bb44989652d1d9d36b16682b682a'), + ('\x36f41e7310403f764f98037df7c9b26629043529'), + ('\x36fac76efe13f821a823e4d66d4dd0c181d64389'), + ('\x370113988aa71fcf7944e3dbdfe5ff57e6ee090a'), + ('\x3704d9a2279d71f5eecba3ddc2bd3ededbf5584f'), + ('\x3705f091755abb06ecc4912167c210e8ad07f90c'), + ('\x3706460d1ade46345346df1a8eb92eb3626f02cf'), + ('\x370795ae3bce5ffdbd374240fbd82b55eec80a4a'), + ('\x370b5865a869350cbb83a17ff86880c75a1eba84'), + ('\x370c65e2fe664e112fc78673078cd00a18b6da6d'), + ('\x370fe9c837a074f4f66e10cdd14809ff96c93542'), + ('\x37128ae89209930944eac642321901c6efb9a6e5'), + ('\x3717f5f8a7dadf1100e76a66eff3ffb59a30ad3c'), + ('\x371cb0cea37cc2830ec246ef40bf65eb5e50cae7'), + ('\x371f28b1c328166383c5aa4387a17b3423e888b6'), + ('\x37207329538ac940b9810cb40102850b09980d6c'), + ('\x37243e0caaa715ffbf774bda2f06656711fc4288'), + ('\x372a611e3f5140246c518e440dcc99c5971f6b6f'), + ('\x372bf97e25ca1dbb386033ca339d346d3fb91886'), + ('\x373382e9027362f0f68a3d5585b7e0d6571fcd1c'), + ('\x373ccbd558ae0ee10e5d713af50c911b64304afd'), + ('\x373fce90aeef33f29870309d98ed485664ddabb7'), + ('\x374f676f0df547dc4dcac3268f573ab99cae6ffd'), + ('\x37551dfde08c36f2e49d6bc859ae00e265b35ea2'), + ('\x375b2965b77917edbe6bb9ba1fb8af37fe2fd505'), + ('\x375c6ab238dae6bb177ee2c6152ebd027e34040a'), + ('\x375ef19cfdf99b0c16b795d4bbe1014535edcf41'), + ('\x375f5f9ac0a687f4307ddeb98de46bb1289b506b'), + ('\x3760928f9b80ca5d3c8f8ea37156225e040851c8'), + ('\x376e4c65f73ff76f4baef2670a06ce96f2ffa90b'), + ('\x376e5fed6f34644dac742d7bc3ef1050d6f1db58'), + ('\x376e722452981b2b94cae8da402af5d6bcf11744'), + ('\x37721b2ec3ab94eb144075aad40534e953f8af10'), + ('\x37741a03ebbc47124db30c1a813f8c06546a2fab'), + ('\x37746e95235bb64ba68184d9f0a99f34b29b4793'), + ('\x3776762cf5c66e491ed224a955101722d5595c3a'), + ('\x377dca26d6af21890b42b6ff804a0e6fa8669769'), + ('\x378237d73281c220f99fc5b063b04c75a910fd30'), + ('\x3784bd92127e9f3c031b9cd55f35476fc6ab1625'), + ('\x3786c56fcccfceb0760c669ca04f0e5bf9101897'), + ('\x37896c5f4a7c156f1a01824a1bb8f807959cd970'), + ('\x378f7a113936fd45d3e80da035671be126aa11dc'), + ('\x3794aa6b929c9e0e624975b915ea65b0b4e8575c'), + ('\x379e584d8f03b08df6591019a36a1736b7520fb6'), + ('\x37a7e0e9b43f376d6ff4799c4059dce5e6d33451'), + ('\x37b668e96b6c5e9f985db7530f14369b74fd574a'), + ('\x37ba807bad0d8c87d11e70426ba73e577d6d049e'), + ('\x37bbc6f8d82b318567f6675d67336ea34d753507'), + ('\x37c518abe507dde599ae039eaa31ade19522d344'), + ('\x37c5c947b11638ab720e6a3ccc10803348e5196e'), + ('\x37d9e721417e778a88036ea6a3528e6833800321'), + ('\x37e08769b88628bcb373c180bb6e1456ce459585'), + ('\x37e4488abbe677ad404e87e6795b6da6c5e73b89'), + ('\x37e819ccf6e70f7ea48d5a713a26423826da4f5a'), + ('\x37e9840953cfe0fd18646fd58641e8d41b432338'), + ('\x37e9d1b614a4235a73d46da84b3083c23f890961'), + ('\x37ea1aa426e08e8c04bdbb3878c9bce4dbf4d61e'), + ('\x37ea4e69d94ba5ec8d73a35b1a857e9909e90bfe'), + ('\x37ed25a32b5960cb161eebdd378a1640a5fc0494'), + ('\x37efc7374ddd3fa038c8ec173e8b4d5ca7c886b4'), + ('\x37f85f2cc43d592bf844a2b48e9a39db02c09eb3'), + ('\x380199780fcaa2b03408c23703b953aee44d7921'), + ('\x381249f6b03e4a4f67636974c3129bd7f9df938c'), + ('\x3816faa4300201b4e51dd9d31eb382da0d3385ca'), + ('\x381ad6f28b93ef98872cb063677afb9391acaf35'), + ('\x381e1748643be08f853306ac085e41da6de462ff'), + ('\x38219597b3f04baf64bbefb58f57251ced790251'), + ('\x3825b06f0ec35c8d8abb349b5ce94fbf946db6f6'), + ('\x382a35ecd69a7d25d6e5e65efacd88ea2be26af8'), + ('\x382c0bc6e98f7582daf5053303527b0e867079dd'), + ('\x383239b18357eb5a293107652327b03ce579cef9'), + ('\x383a281b132f81a797dcf391b152cb280ccc1727'), + ('\x383c539d7f3c0e39fc307ccc6ecad2e71a2443f5'), + ('\x383e022798c24a37575ca3dde39a72ad33bc3636'), + ('\x3841fbb6dcc2d16a3f4851655b95070420e6d044'), + ('\x38461a40401a53291d144a0dd6f2a7ef01262eef'), + ('\x3858b7b892f44f0302e80d886f02a4d64e1d9d67'), + ('\x385a5dfa7e4d9cfcbcd8d4e9f2aee9f822ad21ff'), + ('\x385bfa94b8d79adf803640e2dc5229957d1a909c'), + ('\x386028847466ff4122365eda4967fb2ffa086867'), + ('\x3863e279c8bae8a4be171398ce30478ff92b0999'), + ('\x3865342602672a656608b74091ec4cc1777720a5'), + ('\x3868992cd16a057050f331dd6e3a2b6d266510f1'), + ('\x386ae8a9bf3a85ded2c3721353f8cc3762ca4cde'), + ('\x386cc8278974925f6bf237b561a8388a46cfb772'), + ('\x386faf2d8027ff1e6c6cf78d110970cdc062b387'), + ('\x38707c54b3c1589b516ae0039a682a8feff7479e'), + ('\x38751ce08c0dc94502eb576333da269095b3539e'), + ('\x387d69da321c33ba349117314f868bfd60d742f3'), + ('\x38888b431812ad1b20f63fe353e0dd6267ccf8e3'), + ('\x38889190628ca2079a8097ec1f0ea5654afc52b9'), + ('\x3889a2da351591a4c015d398a4cd7cd2f191de1d'), + ('\x388b4cee1e158d9bc064db1c3cbf600a9bb6d14e'), + ('\x389160326ac86d25c3076925af1b0594c894a5e8'), + ('\x3897438d48e7672c80c1033cf774adcb879b666f'), + ('\x38981612b99494804c2393b8e25d9c218aeb653c'), + ('\x38a0190147d2e144e061c3ced6e9b212dcfb8956'), + ('\x38ae97e072cf682f3ff04aab7706e60ba35b583f'), + ('\x38b271faf385eacb9c9390f35151cefec94151fb'), + ('\x38b294ddf9a4f035e92dd538c80bc71552dd65b1'), + ('\x38b735c2c59a1dc5175f4a9ffc9f4a4eaf144156'), + ('\x38b8353044e83b430dac4dc6ebc9d8346a98b70c'), + ('\x38c4fe392a87a992981201e39e2564b41bffb370'), + ('\x38cab02fddb683e2fcd2676a9f449468e79b4b44'), + ('\x38cacb807ce9f77feb09a73edad922d820068c12'), + ('\x38ce7c1a217863e8156231d335f49b257980b781'), + ('\x38d6a3c0aad1b2d60123d47a3dc01cb42207aa19'), + ('\x38dee9bd00c714ceaf444648da657377d62344d9'), + ('\x38e1a9c8af546ef6641c7c97de7f3fac9bb6b49a'), + ('\x38e48c8f649e976b399dd160cca1fbed34eccae2'), + ('\x38e829fddd607ee1ea55aa53ad12b10d51cd62e5'), + ('\x38ffd2a848d4f563ddbc2dac5989cc09dc8affb9'), + ('\x3905cf15e6a477095d8f33bfd2817be9a3f00155'), + ('\x3906a1eeca7a9eef61a863e0e14a78c722411053'), + ('\x390ab8e122b8f62297e7a2c8430154209829271e'), + ('\x391398495f1d698b62affe9455853580540555ee'), + ('\x391934bd15cbea31be0eee86ab511ea4038c252e'), + ('\x391980d7aacc7af553088e0e5da44884b27434df'), + ('\x391a7a43fa040b1a046b0abfe2f0eaec9913c3c2'), + ('\x392583941ac1da2692246eb292127887a0543a05'), + ('\x3926f699a24a96abfc1693ffca4506f0e12de272'), + ('\x3934534ce5e56b3e37c0fcaa27170d1ad4a0270b'), + ('\x3936b60d7fb078a0daf9ce20afa4bf7ab3163ef2'), + ('\x393f6fbcbcb2dbb537a48815b1c93a95115dd003'), + ('\x393ffa0dcc23bb502a8b401c0cc81418b614c22b'), + ('\x394035d09cf0344618ffea92bf712ffd0a818d26'), + ('\x3946e2fc8e9c6f1f1fa291d73b6489e49f70cc3d'), + ('\x394790ba60721a666baf701f895ae95a17693e5a'), + ('\x3947e1d555007cf6bd872518d46653da32a8c1b1'), + ('\x3950f6bc9252f1592bc283cb914a9e73b996dce2'), + ('\x395157e0616b81bf078bd387ed8893b758cf1041'), + ('\x39594b7ecd08b21aec173d00a4365f740a9a435c'), + ('\x395f2b28ab7645d02430535b8c9da7be4f2bb53d'), + ('\x396590bd513fbf0ad11da288930041df8970397e'), + ('\x396687135847e1513c5ff25474c9debad6c86061'), + ('\x397cef105d601fdc89436caa69d4a0cf73c87745'), + ('\x39839e0c0635a61afad50785c577bac2e68a723b'), + ('\x3983af6762c77ae21ee0fed9808373d52374fd55'), + ('\x3988394321fc91a7480ee4ee824f91f5393f9dce'), + ('\x3990c58ddd07c2b4eb2f108c66c9ed990a4e24e7'), + ('\x3996e1245d4457142aef4e7a41d9f8a5115584da'), + ('\x39a0025afa0ac557e85a596e7e0ba118df6a1a03'), + ('\x39a0c3fb65266f76f0316176d3ade28dbc1c98bb'), + ('\x39a85e1927d5a58894cb84628785e5b729077975'), + ('\x39ad2775a54e150a8ed49a581befda9ff87c7eb5'), + ('\x39b0e5c3b065e6c55fc908ef90d47224d9d223e5'), + ('\x39b11518e6a5d272ef82a7d0201feece4501c80c'), + ('\x39b603b6c25007fdd2a1d918bd56b06a53e17986'), + ('\x39bbf5a6d6ff6fd3805c87f6eed998984597644b'), + ('\x39c13157f6d51a41d8835d3da6c0b1cfd2eef2bb'), + ('\x39c156ab61cca6570f9d9c1f2ffcc9d4c43a95f0'), + ('\x39cc56db8c71b47108813c27820a6316b0153a27'), + ('\x39ccaac3db67576bd5c4ca3aed7f7a637c366671'), + ('\x39d724f4a52b832c8a17a4de6d8a3ac071840061'), + ('\x39e39a2154dae5a8f02860fb58185d5759561958'), + ('\x39e64d79d42a0a790fa60ee4367526002de3caee'), + ('\x39e664dc75d2435c96a063b5ad0f013ada0bc397'), + ('\x39eb62b0144768828d46c69292086440279cea5c'), + ('\x39ebe508febef7d174babcf72dc3461bceccd9b1'), + ('\x39ee9756671ba22767290dec6682d7db69b1918d'), + ('\x39f5afc6ab554974b0d579131cd6086cdfc6634a'), + ('\x39f5e9bafb3210c5d7c97f01a7873ff5efa7b90f'), + ('\x39f5f42d31ddf2b03b9d0588977194f0457807b8'), + ('\x39f6436873547df88f70bf4d7736e3d6f58a619d'), + ('\x39fbcdd391608ff9b5d20cfc7e566f17a72a4ce7'), + ('\x3a038248d26bd860899b40dfbea71492041e6b04'), + ('\x3a0634fcbb4902150425408486b446562759884e'), + ('\x3a09796b7606315bad2983d8bcf3e7dd65764d52'), + ('\x3a0ff2c7027fc18f57b6cc3fba6bb0ddaad871ae'), + ('\x3a109aaaeafc38929caaf1f902baceae26868953'), + ('\x3a17e1d04f4bcdb3a39cefcf5a02442f0e983558'), + ('\x3a1ae8c553edb7b74d4ae2f1d6b761d7c085379d'), + ('\x3a1f2b9672e69d7324f9a18435d263297ee37497'), + ('\x3a26536feae7d16612c4f2fdb93a9825f596f3e7'), + ('\x3a2b47bacdb2feeddd32ee665aa42f590ffd5257'), + ('\x3a355adf7fecf6db6ec4f05e83f2c9bbed4392a3'), + ('\x3a35728cba48db6e877029f12446022983c0e0e5'), + ('\x3a3eca2e7154191197d58c67f13de0d0713975a4'), + ('\x3a40be0dab4a236b7c9aa946d414bdb0513cecb0'), + ('\x3a42b04a29987f45721b588ab8def37a0245382c'), + ('\x3a4322a60dda95415d3a9e8d7b7879e99b3f5b17'), + ('\x3a4546e28bb561e0fd873aac7b4b135106df0660'), + ('\x3a493e38c67bb2496943a2b5f57fc42a16208ae3'), + ('\x3a51a031565c93fb9eba4c01f6bcf6d63e24cf6a'), + ('\x3a59ea845b4e37b4e9ed393fc01e66f07cf11ba0'), + ('\x3a5b266c15654e6ffe1b3a1c33189dd134466d35'), + ('\x3a63b195d6973139d719f774fffc3e8744bacce9'), + ('\x3a6e1477f322368c7ef363a0779d2981b2a669ff'), + ('\x3a7513e8ff3ab2901c95519ebc9da39eeca0d750'), + ('\x3a779bc4c4062a9b2158a99b2767501ebc50d690'), + ('\x3a795f22cc10c72652573659206c0cc931c4e1f6'), + ('\x3a79b9ce521640e17add007b1e11a221a53923e1'), + ('\x3a7e7fd88a99d7947f7471b74e6853e24e5e0153'), + ('\x3a8045bd9d17c1792eb7f01b655d8faaaf925797'), + ('\x3a819f842d3226b3765bac66b622b4469d090361'), + ('\x3a855e9ef8b5d86d3579924e3b68a2c2a770dc77'), + ('\x3a879fbbcfa36daed23fcf3c82da26468e3c4b85'), + ('\x3a89f18eb564ab26c546a80e728e9106901993eb'), + ('\x3a8b2596b11b29e14ebe23b6ce28e2c90c4fd524'), + ('\x3a8c3d11c88bd3dd54ac6d3d46e57148126bb7f3'), + ('\x3a95465885562a79405da510a690397a33ed484d'), + ('\x3a996d6d9fc214c781f8d39fa967fa6968cc8a2f'), + ('\x3a9a74c6eb1097d4393ddbaa08e1c493f62c9bcb'), + ('\x3aa0476976750db08eae85a59cf166660184f3c4'), + ('\x3aa1d25c564cf9620faa56abbc95cd0c3ae576ab'), + ('\x3aa248329a63cf09ff2ffb0d64bde4d7993861fe'), + ('\x3aa48cb2a4bac1a5adf7b2cc5f093cccdaf68f48'), + ('\x3aa899573e5eeb92a84e966170b5ca498360b2a8'), + ('\x3aafe3f87f98bca44a498c58f5e64e1339bbc06e'), + ('\x3ab47da0f27fca69c4640465f98708e3c14721d8'), + ('\x3abdbea7fbb735a7efb9a0f6005ccf335e237486'), + ('\x3ac27d075ca2e848e063da5d13061e8e625021d0'), + ('\x3ac51c9871b6d7aa839b5f75594fb60fde910fc6'), + ('\x3ac6fae0dd293fc396f0960777d09807991ceafb'), + ('\x3ad5b26b0f0b02770ada8edcbf90ebf4e0daf9d7'), + ('\x3ada58dd1962e15fff6ab868871a8d4b81f80654'), + ('\x3ada9367d3e4964d4cf175f46ba7072d29b86e59'), + ('\x3adfca7f0a028de9e54bc35af0fa66c493eb3434'), + ('\x3ae7113e6c80f2759ed69dc06ff101791f0b108b'), + ('\x3aea3e1e27a09b092264717a362e81f0a109b0db'), + ('\x3aeb8470ac9ea6e5d44b48a208886c3796f53ae2'), + ('\x3aed1bf9274cb2757dcce6ddcd736be86d7b6b6d'), + ('\x3af100a7f3d7cc93e0ab8074a9283a8db6e0af10'), + ('\x3af76780d35ac37b0f268bfc4a78d41222225b51'), + ('\x3afac9c47e6028e1dced9cf0e89cbd4bb7cf722c'), + ('\x3b04118d0337caee6ad5f8c3e7227b56fa4f41db'), + ('\x3b07dc3ff2edb0ecbdb11fc4a1d4da508a85dae8'), + ('\x3b137d8d0c92c1aa2dffdf36e22e48aa40547357'), + ('\x3b15f016b9da66579edffa03e182c1bbdff0581c'), + ('\x3b1d64c6df47e1f60b62158b00a9fcc94e13fbea'), + ('\x3b1fe0141eaf493966653cb4c5c0491887167f0c'), + ('\x3b2172c9090b8e5930c43b3e835a0408ccdfbc00'), + ('\x3b2a3e6459b43e527df029ec0a406ac90d4aa3b8'), + ('\x3b3961c202dd4571df4baf51105c56134973a816'), + ('\x3b445745096ffcb1d2cc13038e3c65341353ff7c'), + ('\x3b48e684a9318e5d57ec23b44680e95f3eeece97'), + ('\x3b4b5372e3f25d8c84de87774120e778a234e556'), + ('\x3b4d755985279c0be013fbba2ef6812ecc0e8755'), + ('\x3b51751588cbd410327355f142a41b575684bc7f'), + ('\x3b51dd3476b645abc12f6739ddcf3a0471567b6b'), + ('\x3b57394229c1a12c5f909b7883eb6a85a3a0d8b9'), + ('\x3b5bef35d2b1ab8c80f4bb5ff87aea1c2f18185c'), + ('\x3b60966f6c97c72bd8f7cd9aba3c55b3bad97a2e'), + ('\x3b60c518455dd169159107c8f1f3d265c640924e'), + ('\x3b6b8e145e98fc8ecaab7df4848255fc41e8626c'), + ('\x3b7ac82b8c97b393df4dfe0fddf32dda408ea581'), + ('\x3b7d61bfbe58909c55d8cd45c6259b15529135f2'), + ('\x3b8622f67bffda70fc2d7f6ff2f2ad6f3078fd08'), + ('\x3b8762e6d7b7774baedbaa9d3f7255ffa4511aac'), + ('\x3b87843bdf70a98c33a5adae9cb2f0ec0d416a5b'), + ('\x3b8b5deed94e9c88fb6bb78351b1d65bf442908f'), + ('\x3b8f02d40e93d8f9d7e88535017bd0ed642f864a'), + ('\x3ba7612943300f9f8f09f8361a21269f7b54822a'), + ('\x3bad46c3cb79364b04c463dc0dcb9dfa1c926be6'), + ('\x3bc01c87a02df689ecac7f7bc2b2b4a98d58d5b5'), + ('\x3bc45aaee7d183dffb972cb429bc3326cbf3b629'), + ('\x3bc58f31d2aa8e56fa64b0d8bfe4f55dd15d3038'), + ('\x3bc6b02896762edfea8a8b87fcf979a0b6c30786'), + ('\x3bc9d269f5b019e42c01bf114d6f245284b1832a'), + ('\x3bcf53318d904e9bd6655ec8edbe40eecaf9f551'), + ('\x3bd236cea76d0b10371abbceaf3c7a4a4d1ffb51'), + ('\x3bd26cbdfcb9bdd6f7732a6da64f8fa608eeb739'), + ('\x3bd6612fa0bd87350e578bab65f2f9d55c231a48'), + ('\x3bdb6e2c1f347c22b776323733ff1f78d7f9fb4f'), + ('\x3bdfc54f8c19d0b019175b271e176c9ea24c1e9f'), + ('\x3be8de3ac7c33fd679d5fd4f6709a6fcc4d21bc2'), + ('\x3bed2d9408615d769c0c99ed333a4ed09189339a'), + ('\x3bf511072f8dacb8bd0374fa71d6269c887d3465'), + ('\x3bfc95020f0b714a6e7ff45353ad144663bc004a'), + ('\x3bff5eea00ae7b1b55894bebb9269540ac4ea0c1'), + ('\x3c0cf1ff535622b1d906139b88de30c272c0655e'), + ('\x3c12dc693c2ddeb3b74d968991ad44c4b330d499'), + ('\x3c14e35c6b90b60f2363303f5ca83ea036a1eece'), + ('\x3c16f5ed84714c66a6268c4832a5593e4a5bead4'), + ('\x3c1bbd0aea99ea845b7d7403d60bd8019575ac7d'), + ('\x3c1eca5beb869dda9af8478cea3715e26a53ecce'), + ('\x3c2014d5354994e8e5eaf449656791bdfc85240a'), + ('\x3c26a84dab9a54ea1bd458717697d9816779899a'), + ('\x3c2a5b341e11359ef39f349aaf6e101a0bca1b13'), + ('\x3c2c34efa814817964a9f55e47265bb175a8d96b'), + ('\x3c326701f201e878652be9a583d1b2133e6ef7ea'), + ('\x3c4409047316986de71cf279aa8d8fdd6724f49d'), + ('\x3c456ca874ebeeb8102330acd4b5380718f353fd'), + ('\x3c47b4ac41adbeb700c850778610374686259a17'), + ('\x3c4f6f80fc6bcfea38d985582af5215effaf8da0'), + ('\x3c4fe4b9b1e4b93e35cbc274832ad10e09c990ef'), + ('\x3c54a1a614f88f38ab55bf2455d0e104c5b32c1e'), + ('\x3c554cc44252916bca8708eb5d594b76d27e9ff6'), + ('\x3c576d48ee5e4146b1f7ad45d9e98c8e2e0e5a61'), + ('\x3c578914c27e6bf95e97995e009a820b5ca68674'), + ('\x3c65de948ca580d4df4c23aa725811cd7afb7dcc'), + ('\x3c68e16ad11c8d9ab032e81d42d735398502b478'), + ('\x3c6b511da1c18c91ace63211197ef1e74465032f'), + ('\x3c6b6c2af4f2075f80406ae560f1fb3a7da2b727'), + ('\x3c71d7e6894f3cfb331987c5ad36871791d1e7f5'), + ('\x3c765a96c5b64dc7883ca21a2149140f66aecbd9'), + ('\x3c8d208d7c194230f4027aa6faa4fa471924dbce'), + ('\x3c8f104ea06d55953b650b15174e83e875439fef'), + ('\x3c9b9882b822f720cf0a4022ee10d860534bce9f'), + ('\x3c9eab127ed1ae9cd84c41a0059b006b3e65a0b7'), + ('\x3ca128d4920a4a82a6865f66c502532dd77ab241'), + ('\x3ca59d34fe4fee7eda1601ff427ba880e3a872b2'), + ('\x3cb19f1e9c1a2be94488d90884f2f34e618c5ee2'), + ('\x3cb28ea14b91998e073006bcd387d01984759bb9'), + ('\x3cbb97e88a4cd261a92ff6a7872d89a32c89bff3'), + ('\x3cbfb00afaf231996a2ce84132698910a8ce5df1'), + ('\x3cc101a74f587d603bda3ac17a24d42d3c87d99b'), + ('\x3cc1ab322b74b13a881e95aad00bda39c6073e20'), + ('\x3cc2142fd1ba12645c0789a050334f3c6aac461a'), + ('\x3cc54c5184ad84cd5aec26d3be434e993f8c5a0d'), + ('\x3cc96395b6aa13a88563f61b4a98053e37a9c0dd'), + ('\x3ccd639fd55d43d699d33fe40eb46fd028322ef4'), + ('\x3cd5a7950f24c474ac13c309a4ec1160ab45a5ec'), + ('\x3cd701a930e914b898fc536e6d23a64e8d59d443'), + ('\x3cdcb3cc0e911778ceafa83aedc4fd7e3b180dd2'), + ('\x3cde76867a3a43c59509f267c6daceedc312d818'), + ('\x3ce3a9d158a4f8c157173843fa972054b72aa58a'), + ('\x3cecb4634f8b93c04cc3d0282d731cf6cde89601'), + ('\x3cf20d57b0b8258463711cedd592007b0b5cdfe8'), + ('\x3cfbee96228755ecdd8ecd052796ef189e013511'), + ('\x3d055e45998316912284d008790a8f2b44410d11'), + ('\x3d0711aedbc03b2766c3c5bcd6281f3cc2ad5240'), + ('\x3d071634f313202aaa1264fec1d59255b23b29de'), + ('\x3d0a979ec6e2626dc6e1b010cb68847c11b64928'), + ('\x3d181ca6911dfd6380fbadfb17cef6c3b9f2f4f5'), + ('\x3d26e5045bcab65b232d168bbee9d958acc47a83'), + ('\x3d2751a16ee0180bb188ebf79a1a22b1ec6f69fd'), + ('\x3d2ce8c9dc8b6ebffde3bad957f133b08c655ef7'), + ('\x3d2e5d4148bd7b55c290f183365a0627379eae30'), + ('\x3d2f216d50fb2cc440b31cfa4dce579339bc632c'), + ('\x3d33044398215b1af6ccfafb585cae4a24b31b74'), + ('\x3d39d0cc823fd67a51628fca5b421aa568084659'), + ('\x3d3b7a2984aec7f247680b1fe7296700cd2a3e45'), + ('\x3d3cc10792e4b8b6395d706ddf6de0b01f6c3b4b'), + ('\x3d3d61c4fa52b818aad1b406122c757c8888c79c'), + ('\x3d4c2fabbc29320298c9665fdc40242cb92664c0'), + ('\x3d4c96de36f94efadb9d3d8ab7c478f8ed5184fc'), + ('\x3d4ce65dd7930b138abc00775abda9d7edd51636'), + ('\x3d4d2d132e01ca6c4ac73a904c4f54a7af13c7e0'), + ('\x3d4e48a1e495142e6cea165ac9c9c1ed52d73e52'), + ('\x3d4e6fe2f9f5282806cda0553d3ab321605ae37c'), + ('\x3d52eb37a3f442151c43d99a68d650a9051b640f'), + ('\x3d5d3fa4d4604d905eb211c51fab7dad5a3b53f8'), + ('\x3d61a67bd3627ae28d5ef7e55cd34835c65a43c0'), + ('\x3d6357d6d944ef8244a76cc705f594e7b8b783b1'), + ('\x3d648035d0aca2dcbc460b7856d5ef257d4b8478'), + ('\x3d69d9618e63ce82369b696012ecc5b1dee22ea5'), + ('\x3d6ba743d21803919a61cabd08627672960d8fc6'), + ('\x3d76a397b0a664c06ff49eb047e476d66923118a'), + ('\x3d833572ced6a79f3d2cb6963312dfca249b3730'), + ('\x3d84eeb235ed8f13848aafb7035670a3a44fc933'), + ('\x3d900ae25f0f94ecff39c0e28b6fec9d5b32fb71'), + ('\x3d92444f210d02433328b476c1afa8556de71324'), + ('\x3d9cc065a66f56d8ab83662c2b67bdf7589aef8f'), + ('\x3d9cca45c0413e3c2962f53f331325daa9522180'), + ('\x3da55a7829f6822732785261f600cc9c4efa063c'), + ('\x3daae0fd5fea9e7e730942ce5b487ad33b2400f3'), + ('\x3dab9f7ba6ddc5bc88b12f35827e5cf6b95760fb'), + ('\x3db06b9fbb7dbbe6d4a058b58eae72e0c53099e0'), + ('\x3db4258173dedfe8fdc62eb505bbcac58fc2f19a'), + ('\x3db54e3e49dd44f0f8a523adf840dcf653f489b4'), + ('\x3db64d159acfb75dededa6ee309e75d9a843c36a'), + ('\x3db6ca58b17aa5c5f9e8cb1431d39e41e743dd80'), + ('\x3dba6ed37e7aab3ba28badf81d19b937db071f3a'), + ('\x3dc14fd82febc72a78f7fd4da93b5d76d3aea471'), + ('\x3dc1d69f8a2792ae3c21debde96c4443f67eae19'), + ('\x3dc59711e7692e9e65c45841247f68799c4080be'), + ('\x3dc5acf1d5411a5cc7ef15aace824228789f712a'), + ('\x3dcb0a7e2a596e4d098910c2fb6896996419ae76'), + ('\x3dcecba963edfcc5755c52d539164a1e0152f473'), + ('\x3dd1a8ecaa63e37a383b213a7708d597c62ef995'), + ('\x3ddb202e2143f9b0f05bda9f5ff8e990df8cc55b'), + ('\x3ddde169a1bdee9fd243db4df5a705af413f77ba'), + ('\x3de44ffa43c1f8c8b97ff7a2ba1380b3d5128edf'), + ('\x3de8b5a2c10d75fe7a490564189f2c555b349a18'), + ('\x3de91239683669a7198d131e0c2a612e02f3ad99'), + ('\x3deaa9a0a10533a0c5f4f5a83ad3fe1a425406bb'), + ('\x3deac96eea87a212be36a8b2c167de368f4b2348'), + ('\x3df9bb22d7f49e2957cc0077f12afe209fd2488b'), + ('\x3e0999a9e3d8aae7dd31fbd31e3097b09b401106'), + ('\x3e0e37a397a37d0dabe7c1a052a067443940eefb'), + ('\x3e0f474714416eddb651b88c7de50713e24f93c0'), + ('\x3e0fb59e30a1ae071a7dac64f0cc4d493017bd74'), + ('\x3e1282463032b1e30a2e36c89097d7e460156310'), + ('\x3e12c74493c7a2fe64f5e17625826508df2e42aa'), + ('\x3e1420a71c4bc6bc28272a03e1357fdc28f3352c'), + ('\x3e17ec70b16c9a7264724e392e45a281c89d1b4b'), + ('\x3e1f34571f860297284188baa8ee96ad66103c9b'), + ('\x3e24a022c5f2578ac438e7a52c10e1ec52691190'), + ('\x3e27aced316debe419546e41d260c1c5de62d464'), + ('\x3e2ab1b438a3c7e6d48613d649f4552789a8781b'), + ('\x3e2ad8b62be4c80be3bc0c4e2ace093765c1dad5'), + ('\x3e2c2b4089dadf247bea0ce8711a6e3a50b99b99'), + ('\x3e2f991b88346dcb0901e8001e2802a51c02585b'), + ('\x3e3502a7c92254ce7c7e732771251f0a131d06f1'), + ('\x3e3cf83370d62062a1d8ca2e30c640db56528a57'), + ('\x3e400f5af79b00b54df2b28de3811de72bfe1816'), + ('\x3e461592a88fbf5ce280ea09985e285be6937290'), + ('\x3e4750c0dc80572f6f84a6b069c8c33f29c60d2e'), + ('\x3e4e4fd372630d7ef18bd6706f29e3f7cc09c403'), + ('\x3e4f4d0319cec0abff282fcc981c69205d47ab30'), + ('\x3e5539de6883bd171584c52abffc2193628481f4'), + ('\x3e5ed2f7cee6fa637d8ff522b09e55d0387f2a4c'), + ('\x3e5f4becd90ac5559ecfa9870ed7042ee0a8e706'), + ('\x3e62fa06c9ea31906884b446efec41309d6870d9'), + ('\x3e64780cf29b389221c72727deb4cae6e019f445'), + ('\x3e66610f21768e670ad05ced17ba5c18bb530923'), + ('\x3e6e2e76134cd6040a49ccf9961d302f7d139fcd'), + ('\x3e74ac7d5bf8fe415b13652298a56a5a51f8ac8f'), + ('\x3e763ab0ce6d085f57d75b4790c6652b23171d78'), + ('\x3e7879a48617510f29d26ccf9df93d126899ebb9'), + ('\x3e7a50e68a9d450d97093d6057f1bacf906f5fbb'), + ('\x3e817a3512fa07a626da7c9964c31d36d480975f'), + ('\x3e921e563971f5885f5bd43a442e3084980af211'), + ('\x3e93c85a52526203e29ddf86b2abd714ac2ed850'), + ('\x3e93d3245ad81df35044c07b92d0a78c00900e7a'), + ('\x3e9cb38bd2971eea9ea251f50162d7d3cdd5dd4d'), + ('\x3e9cdb7b414300af50ba4675eac6b78523428c48'), + ('\x3ea38f27932851526de3df6382926b0a3c170d94'), + ('\x3ea78d9321bae6854435fd254f6c1d7978f50f5d'), + ('\x3eaac4de6d04643f8b81a08d204d77bca159c074'), + ('\x3eaffecfcd05ee9817a6329985606494a8841f7c'), + ('\x3eb0f566f48727cf3b52fdc5311f2a160a8adf53'), + ('\x3eb2c8970d4ae41bacc7b9b769281b74a09a14a9'), + ('\x3eb87f3e617becb86c62400148e318b7cc4b7cd8'), + ('\x3ebcef7ecd0ef53bd46e0145723c64e7383370cf'), + ('\x3ec43ff74adecf735812b07b75ba080d3d291ec1'), + ('\x3ec6894a1b74196ee94b0587512dbc30d0a900c2'), + ('\x3ec9099e87ae2edf2a7668eb81c835a8faa6acf7'), + ('\x3ecbdc55fe0206f72664648c34f423396ea23300'), + ('\x3ed141c1659badf56964bfcfb3beba49acea6f42'), + ('\x3ed92fb05848a6d2b9f07a5b7a30ba22ae24c8e7'), + ('\x3edd1a61b44ff2bf8fe743c08c746dc986d463bf'), + ('\x3edee9d8ab795dace5ada1d30435d699a64b3115'), + ('\x3ee414ad40b0d1ddfcf67cedfb76ce625bfc2ae3'), + ('\x3ee8a3d12d717e10001a02a8cc2d90285220177d'), + ('\x3ee9c076db9c8d4c6821a4ef36d26971103f0117'), + ('\x3ee9e4b717e797f21684580b754c710ed29fc32d'), + ('\x3eeaf89ae8e7bddd1e8b857123f3bd1e37a2c27a'), + ('\x3ef0c9841866863b2331e30f0cfbf0510891ec5c'), + ('\x3ef530980382289ef3204d29515760eea37de686'), + ('\x3f010091ecd7958c9dd521b3e955411651cbba8f'), + ('\x3f03dfb1f6e7f43ac6b9c69ff9b232c463d57f5f'), + ('\x3f05f338e0ee80d8918e7bcba9c4983b746b36bd'), + ('\x3f0c08ba3ec18a230535643efe91a5b23805263e'), + ('\x3f0f94107fa63b5ea5776016e169bf4a0e8055f4'), + ('\x3f102ebd5aef1dfe0d8b0bf53958d687b1e9fd1a'), + ('\x3f16af71969eb57b09e7495ea4a2ef77c2278878'), + ('\x3f1b8489849fd9eabe2e430795be04d0d984e36a'), + ('\x3f28eb12e9c22fbe42d52ec123365fa18375af45'), + ('\x3f2ace6a495a9686d963c43f1e36b55a383a6f97'), + ('\x3f2ff2d6cc8f257ffcade7ead1ca4042c0e884b9'), + ('\x3f36a0de79929b677e5920f2c1b4762376ade148'), + ('\x3f38ace8390e76bbb095ba0e18b18d7974c3f202'), + ('\x3f3a4109f5ee741e2fb60bfbd59b86b481d16987'), + ('\x3f474340de0055bb11980ac4b719048567ff2af1'), + ('\x3f48a8cd042ccad18abe0dcd8eac0fe8c07daad4'), + ('\x3f4c5ce2f02ee5ecaf6364442a03bf34cc2d1a9c'), + ('\x3f4df1eea009ab6884b149ed068ed625b89ffe17'), + ('\x3f4fd6bba23e432e8183451aa992abf2cba88e34'), + ('\x3f533cb1deae31c6d47e7e9ad0a6614ae71e23d5'), + ('\x3f5ba6718e0f3143955f2ecd297a2dc913c15f92'), + ('\x3f5c20896d1c62ef67e8c159a287c1e052fcc304'), + ('\x3f5f5dbed864db4116ad6d660b75d58b0a99b1e6'), + ('\x3f65fdbcaf449f73faa57e2eb15c5759c655bf65'), + ('\x3f695107476f8297be69db480e23526caa3abd60'), + ('\x3f6f0be947bc3a56a4e62f5a0cdc90ced185dd21'), + ('\x3f6f8c3bd304ef9e63740ac74392fb86aa36cd97'), + ('\x3f71f074604a1787c847af4cffe7751f08173621'), + ('\x3f7319b305be7a2c114245ed9d5f5b16976c4812'), + ('\x3f75c8d78bbfc4fdc462ce0d859560ed35d18418'), + ('\x3f7f06c57cc545dacb588843be968d6009b3890e'), + ('\x3f832607fe68ed70f38e1f2613e569510604d51e'), + ('\x3f9361dd1d5b54457ea06064fe17da45425e410c'), + ('\x3f960f28fbe5abacbe571dd699987304b7ff5b05'), + ('\x3f9f94809f3b69259309d09b9b934489a97dbd5e'), + ('\x3fa9852e0e8b639fd2e2bd61b7688c3e0512bd55'), + ('\x3fa9a6e0f86d37dd8d5afebcd2ba6cc952974abb'), + ('\x3fad79f1d37b6f11e29eae454d1ba1d5f6493b32'), + ('\x3fadb1b91ca75fcde41633b04bd393ae93641b98'), + ('\x3fb319911de5ddc70814de6f7f7caa1d84fcf62d'), + ('\x3fb63a88301192c6b05f3d8cce47b164bd815e89'), + ('\x3fb654f1566b2e20ee7eb5639874513ebc293600'), + ('\x3fb753d08a35da86711a6bbddec80c1c3b6d09ef'), + ('\x3fc75b15775b28e9df6cb90b46ba46d46f257b14'), + ('\x3fc769a9496d84f70765a6bc947a27078995efdd'), + ('\x3fd12fbab954862be3b63bbedd66057909f58685'), + ('\x3fd7535eef2fe5208ff83f50023c58b1192212ec'), + ('\x3fdb7f0e6b2c83e1b46996ad3a7aa2c13ef2f702'), + ('\x3fe905c9fcd7bba2bedfbe2a74d6cd01797c28a2'), + ('\x3fec0fbaa243bda4fda8811282e412f88469e055'), + ('\x3fec9cbede6357bc3bdeb3e47d42628e7e39f489'), + ('\x3fed399237f33d098f693d97810abd5249f8320a'), + ('\x3ff006b8261285631ea45431e79010fef4941c5c'), + ('\x3ff70efb4e6af48791af122d1bf672c18009c307'), + ('\x4000e173a733ee8b6bcdac2e46a26ba37963e1f7'), + ('\x400460abcb0acd784fd8757d905422e3fa04dacc'), + ('\x4004d3eed3a5598d0d29d352e5bfa8aaf28f2a22'), + ('\x4013bf88a5e7b4e9ca4f8e53774d4f0a1346b52b'), + ('\x40191c203e4422f8ea903cc5aff0382d4db27847'), + ('\x401e34ce33297a491aa923a3b6f760f9417f92ba'), + ('\x401ec0f36e4f73b8efa40bd6f604fe80d286db70'), + ('\x4029e67aaa879f2c88fcb06200e9a0581fff3f8d'), + ('\x402c2ea9a2cbd24693e85b6119845a5706821ffa'), + ('\x4042288cd3a102f0c253dafb9ae00e8f0eaef69f'), + ('\x4043fb1db0d2ee29bb7fa85b92f9d669497abd7f'), + ('\x404b67d14c5b0a8556478cf63b45c33c5058811f'), + ('\x4053b6ec2b529412dacee97bf5f5807ca9c298e8'), + ('\x4054a17b018c45ab9ac090816ee1720fce74a01c'), + ('\x4055ca3d38469c75df70bf14439f73c6af8b1b5a'), + ('\x405c95c56b3ea31422ed162eafd8052b74057bd4'), + ('\x4061ac486b3e03c3aab5124fb1c0d2789f512e67'), + ('\x4063c21348f3ec0c96c504b9ae7e33d6059fadf6'), + ('\x40651268c1271ff720e52dec327da4ef7713ee12'), + ('\x4066b76b5942802561aec3942bcbceaf0b4b3e2d'), + ('\x406bcb19b3ba4f31f6cdea93f03ea82038ce7ef1'), + ('\x406de0600982e2656b764e1b648ef4b1d9de8e72'), + ('\x406ea23041426c40688ec12321f662065435045e'), + ('\x406f53c3d6124c5454ea19bcbb8af017a96d9596'), + ('\x4072f2af2680c66d62da89a3ec659e254ec30974'), + ('\x4074a32b458e0b731555b6d95ae7634cf11180b2'), + ('\x40760004eb39c6ceea8ed6e5d303f481a82e5787'), + ('\x4078dc65193bdf2a7f99da2629cbc582a96b860a'), + ('\x407a578d839a65b9d12e6d1e1243608a16eec276'), + ('\x408247397c7dda35e57330f00a3ebe4f33bb68d4'), + ('\x40840e4f7d013efbfa8e783132f83155ea93fa41'), + ('\x40885dc32c7b6f00520cb52f7428a72495cda397'), + ('\x408e058ba6812c70cc243f5f268058f7ad6aa585'), + ('\x408f9dd141d2dfcb9cf828bcdf21f1ed3f6b8270'), + ('\x4094ff9202decf8a5ab9217ea7830755285edf71'), + ('\x409dc6bbc6755a530484f9f4c8b2f83874b7f034'), + ('\x40a3e3b480604423b7348e7990ac587a527e0ecb'), + ('\x40a771cafd1bddad442302788a4859ab045e96a9'), + ('\x40b6a3755764f30c1384ad7d220c5066f683b2d8'), + ('\x40bf2925af1401c8c932209e9644f571c9094168'), + ('\x40d86baaed5da09b771326634051b541e2a4390d'), + ('\x40dcfe6aeda91d36f9e35480db057924f832ceb7'), + ('\x40e3cac97aa5792c40e95ba11b09951e701445b0'), + ('\x40e5699ae1afc5ea21518beebb0abd4ec47fb75d'), + ('\x40ec828b76f14097e191286ea3b6b81edea46aed'), + ('\x40f4a54a23608f8bd6f948158f243df406417419'), + ('\x40f80ad4e4e5753a3f139d00a52762f075637899'), + ('\x40f891b19bdf1a40863d14cdcb2fbaa5d7c848f4'), + ('\x41011801e4fc537264cd2ff4dfe405175f06f674'), + ('\x4102881c2c51c36d24ff05df234f93198774f07a'), + ('\x410333c364e95a1dcf9697b9df529ef838c7c9ef'), + ('\x4106370bf0d4290a0c13a59b07a2d31a1bddc7c7'), + ('\x4113115d6728fb2a30095b3289dd8867d27e838e'), + ('\x411655b0241f9d979d88657d6993019bccc74519'), + ('\x4119140c9ae3b454c551b9f938175f2bc38e52f1'), + ('\x411a68865e075a39fe900122715b433657a30804'), + ('\x411d2a6c4d304f2b0dad3a7f7f275f34ad036b4c'), + ('\x41264e41898f10186b7c5fd7e169b2033f89ac0f'), + ('\x4128d600b2583a12278ebdef93d6ea2f7acf6622'), + ('\x412ade27abf7586eaa8ccbebb360f2132506eef2'), + ('\x412d5dacbb0bb90c1b6a718b89c8356b7c8d5dbf'), + ('\x412e2329452a4f62b3f3ea903176b0a8a817fe16'), + ('\x413bb0f06f619468313a5d77054f902930d0d9b2'), + ('\x413c7baaf2b33037efb1c16b61b9c9fe59461769'), + ('\x4140ff950f65afcdd17c08da03d49db2aeb62f09'), + ('\x414504a3ce1792745ea54cb34da5aa2078329840'), + ('\x414fc9759ea84da135f022aa74481adc645dd8cd'), + ('\x41607d39ca88cc52d50124c35f9b7ff24cd8b0e0'), + ('\x41622e728fcdb924bcb13b57ebb8fc909b1f8b34'), + ('\x4169682eef8818170d4a482e3d3cc0c63e0633e9'), + ('\x416d682f12e1ce9c314c9416832f4a0154c481d5'), + ('\x417036235df07e10e93a1fd21a6699c4e80a2051'), + ('\x41745420d7bc31d1854b56e0872be2a410a14af7'), + ('\x417cb2f51aca2be4b425f2328ec2c625165b2884'), + ('\x4181423b80ed6ac0590f271957ac19005d402210'), + ('\x41839e59ad5ec3263e5e2a842a8d251b889e9b90'), + ('\x41855a796eb3cc1497524ac7abba9017a9f3c7e2'), + ('\x4187373d8b86f6f7cffa356c6d297da1046dd340'), + ('\x418cd46a2efefb5ed541ba8cbb5e7d4515a2888f'), + ('\x4190fb301aa0d964670706a587735a10b81a0db6'), + ('\x4191b887ef579eb7a13ada94ffcc6ac48e84fc0e'), + ('\x419293f34cc579ad2b75b1d0f088233494361e7a'), + ('\x419372a004683af3c2f2abd363520e4b0df37f00'), + ('\x41938155953c7fe5e1578a513dd494016723a1cb'), + ('\x41944ab161470d6ac63f9380bdd66214ab1e8252'), + ('\x41953f362083f996fae1558ebb4e38fa1cfc2f26'), + ('\x4199ea4a2775dd09b74ab6a6d73febc2ecd917dc'), + ('\x419b6c1940871b7631dbec92ef3c71c2c5a44eec'), + ('\x41a064ad49fca90271c57f8f6332c587747b8ff3'), + ('\x41a3dc56a16bf9474ec4e52c89ed2d206f8b46a5'), + ('\x41a45b5182849039b1718252953b7298601587d2'), + ('\x41a5e16835b46cecd162c079c7cf5f183e3facf2'), + ('\x41a908bc760425d9622b8cc23f3ce496eea452f7'), + ('\x41aa4940edd056f22c51e94bf12f63d13631b861'), + ('\x41aac04da398ad621e41429527a3830896d71fb2'), + ('\x41abe3916595fe450aef499ca3394be492926bca'), + ('\x41b096eef4462ec8c5d432f35763cf0b49a30f75'), + ('\x41b5ab519daf366aafd4cfe7254dc85e6ed024d9'), + ('\x41b76bc1ed1f7f38c782ae5c721f1121f9482075'), + ('\x41b7a0b03ad9e7e2cd6e078ecd4254ea8daa918a'), + ('\x41bb2baed2b8897e65f799241fdff63ff8df2d81'), + ('\x41bc6ba179f8f5073a88bc1dcad294a7cbf8c88c'), + ('\x41be4df7ed3e6af75eb4f8c9529794aa2d454c71'), + ('\x41bf7e16f14eb7cce05ba8fa6d59fdda547d97fa'), + ('\x41c0fff028c9df0964fddd1c9b15764fa6b8e826'), + ('\x41c4b7b98fbf5c10495cb3bf0a53b5e86d8e6cef'), + ('\x41cc1e753153e343a0ab73a341f545fec9eb7816'), + ('\x41cc7ff3f98bf595ee7f5ce902da07fef539e993'), + ('\x41d1d7fd26e62e3a3256649d0c2f021840353930'), + ('\x41d2f30a074304c8c3c428e01b5f03ec1be6c6d0'), + ('\x41d60f54bd4094768db891334d52cfabbff2495d'), + ('\x41dd0f4687f79dd70e2921d7cebafbcce9f64f37'), + ('\x41e21af2510ec33f8c830ffd83f23bd0369bfc12'), + ('\x41e50d4997832f7cf962ec546a8a0ba81933d552'), + ('\x41e6ea18cda3e89b4a823f26446d396b14eb8d65'), + ('\x41e7f362b63735e3f130b87eaef9a45565c8cb35'), + ('\x41ec8358e590d50c41086ca880584923e57f024f'), + ('\x41f1dfe3ee62df65788ba871fc8cfde3cee9de26'), + ('\x41f3e42d6eefb37fae8f02e7c99571c309563b98'), + ('\x41f452d022b0bd11b255b09c4bb29f739eaa43a3'), + ('\x41f7a2ec31c9383cdd1dc75867da2d2fda2b1c79'), + ('\x41fdd9a33e1ca0c29b007bdf56f6d22a2e63a958'), + ('\x420018de163389b1183038623207b3a507a5d636'), + ('\x4200911e1ec5d8abebd4b565da8192767751739a'), + ('\x4201f9de7c5ffe1a9f84a11544be4cce6a243723'), + ('\x4208051a2d34f4c599b6742129daaf2f45f7bfae'), + ('\x420b0201ff38a9c29773fddd041430bdfca8e6e6'), + ('\x4214a006718961d91fb5c59e641be4daa28f9d69'), + ('\x42157ceacdb1670358d26eb22db26f49a890bcec'), + ('\x42163ae852a363cb74a3de5b82c9b5275548bda7'), + ('\x421a7ed1df3d8bc36659a000a0f6eb651686f208'), + ('\x4228b33bd2bf28b6398a5ff6343258b117140784'), + ('\x422d88577fdf03b437abbf478b77e2d7a4fc99df'), + ('\x422df8bf354ceffb7557788b107982750ff78190'), + ('\x422e02504057d56c665446fa2d60b86dd9ed00bc'), + ('\x4231954be36368e2d7b53c152167fc3841431c04'), + ('\x4236ec52b009e9116431dbcb3d10d7fde635b611'), + ('\x423d2ca5ee7562afcaaf10e42f51d08b57828bc5'), + ('\x423e8cffdc19991211b2f9c6320c029ef9c6f518'), + ('\x42452a1492f12fe6b1d4b5315e3847d50d4c4686'), + ('\x4246453cd91e628aa8392a53d32b27c35f624540'), + ('\x4247995b278d764cc5949360a90be68034ac12db'), + ('\x4247bf4fa9c4e0cfcfffb7dc7124b90d7e491bbf'), + ('\x424eb14168cc57b2d1dd4dee8941b2adcbb8fa23'), + ('\x4252d065256bafe2ee21ad7ba2f5fd9e79b686e7'), + ('\x4252e493db3e5bf1b0cb607959c53ef9762e8c07'), + ('\x425e58c974010f9008f768372618f9a24512b5d3'), + ('\x4263ab1bb0b9ae6f8a3ae334542cfd9a51a18d0c'), + ('\x4267013e639ddffee6d7df91956700cd9609c6b8'), + ('\x427162672a7391df1c9bc29735ed94a07ca95db8'), + ('\x4271741e3b5bd34a24ceea4ef6f964a70c79bd30'), + ('\x42769d2d8e84a7795a60da2790ec66f67c992d32'), + ('\x427ce92c9742dc1889b36968ca355799ade610be'), + ('\x42829537fcf539d642de26def272fbf67b74e5fb'), + ('\x4285fe328a0120a122fa8cf0d7f9ed378fb95e14'), + ('\x428b3b1a5fb81743e1f5eec609ba89d13ce30c3d'), + ('\x428c22403f2efd45d6d8e4a45397a24d035100e1'), + ('\x428ee223b57f024bc3297c2677294ba24be036b1'), + ('\x4293d9ba99499f46487d37c5fa236ec7593d7e6d'), + ('\x429bbfaa0b57f5cc4adc7a42501f586947ed1861'), + ('\x42a1b5cd1d18db606d2138618f79056262baca86'), + ('\x42a79c233f604df02ff34edcb8b652bde4d25d95'), + ('\x42a8b2259608d2cffb01188265d40587c4fcfdc2'), + ('\x42c129c80129cb3405c807b19486672d1bd02992'), + ('\x42c89bd6b968517a02d4c014ae995bc394102428'), + ('\x42ca3a044a005fb4a2ebd177c9a511b96b2b38fd'), + ('\x42cb8def44cfcf75ed769a0565293a13b27ea5b2'), + ('\x42cbbff4bf5e5fd8133f40c6ddbde1b0a26024d5'), + ('\x42cca0670717a24a3ecd470bd0e61049cae94123'), + ('\x42cfebd87839777502f0b151612665d49f9e139a'), + ('\x42d5a2df037945dbfea307dea2d8b4953f11edac'), + ('\x42d7684573bee93510de20cc03abd6084d36a065'), + ('\x42da58c0155060d341dd29edb99a4a0d7517e915'), + ('\x42e02227b7569f98bd9d87201a752e3c3e08b2c8'), + ('\x42e277fa17cda7531a045b4e3cb08edaf475021d'), + ('\x42e974b7780a16e8ff311d5b8d982ccfd72f911c'), + ('\x42f99f08e88e0ee2acd295d497be81520ab48b8b'), + ('\x42fe7346c47b243bfda299b7afd759648b4cb14a'), + ('\x43019cef89d7994319f8a7fbc1225adbd3a58c17'), + ('\x430a5bfe2731fb3e70813957459123ed18677a0f'), + ('\x430fced85134f35c36420e8c856676a84d6c9e04'), + ('\x4316c84dbe4cadbbd3bfb3a80734a0726dde6f34'), + ('\x431c380cb599919d9fdbd19467477723e3b8a690'), + ('\x431ceb3aabe3ed30229a00d6f942723bf9ef0036'), + ('\x4322079f9b2492801835bfa9ef08af273b4751d9'), + ('\x43246dac612c4d58e74b41d1add39243c97a0ca1'), + ('\x4329f5b45c6ce13f19b3936d723a1d671f221b66'), + ('\x432f981465d404eff4d753214b192f8f27e3f47f'), + ('\x4339322578126b48944bd775178689aedd96dccf'), + ('\x433faecfc1ab35fa8ec0e3f83c347488ea526ae5'), + ('\x434206a7612e3155c6a5df689e8c1cdf8d454932'), + ('\x434207ad3d8e6057a0797a3eb565e50f5bd03a62'), + ('\x434a6fc7c3916788df76678dd330c4be6ddb5829'), + ('\x434af705fe14c45554f438c6c26f1f6b17507a60'), + ('\x434bda294f0436ef8f3d3a42db6ec3cbeb0abef9'), + ('\x434c83435aaac176b8d2949330b636468fef2c07'), + ('\x434f838efaddab136d81d76f670746e8d79f9c13'), + ('\x4350054a6ea107f449b7491505246db1de824ecd'), + ('\x43514cd4a6ca1488861477f207076027fa1d505f'), + ('\x435c1cfeb4c8a6c6e4a47c632360f8c7a7fb631a'), + ('\x4372d1f1087ba0796f270f8437df0d9415f33291'), + ('\x437b029626f5e572363d923707c6a8d200d5ce60'), + ('\x437d2335e2a426d0e6fb3fa029f9a0a7bff696ad'), + ('\x437e2cca647e324e680e02b4eb1fc61678666586'), + ('\x437f8e4609177f72f8222facfc949ce2b701fb8c'), + ('\x4387a2a6be56928f866f2a871f0489a9e7426b2b'), + ('\x4389fe6bbca48597da20898c171182f183644b3b'), + ('\x4391cf97dcb12b6fc807480b28ee63644ef71040'), + ('\x4396042f3e18429e649e81caaac090c67610e40c'), + ('\x439cbc36300ea32885e4c9754500c98dabf62485'), + ('\x439dfd6d3f299378f7bb18a7115d867be7d9d510'), + ('\x439eceac7749566ceb6eaf5f55ded6af3aef57b4'), + ('\x439fde7dcd454161819e50cf430ad6b2418856f8'), + ('\x43a7b77f7130b0e2631eae56ccfbf54b6a857938'), + ('\x43a7e85006b8473c77d9037eaad5eec0e4a70cf7'), + ('\x43ac2dca3182248da0148a9e8458c2bfd8e64ae4'), + ('\x43adff127f3c828cf6560fcb150f6be952dba9f4'), + ('\x43b17704e2b412c8671afd176bc0c0be10f57b93'), + ('\x43baf79eda7202c8954d58a7476221e6f04257b7'), + ('\x43c79ec45a745a2fb79ae6bacdf7521e9655715b'), + ('\x43cba3bd1bc693f018d807f96027e565bca13a6e'), + ('\x43cd5bcaa843c944117ba10e8136b7ee937f0ed8'), + ('\x43ce2a83a650d04c0ea9d0bb124ec8c511a552e4'), + ('\x43d1119c499d6b31353f06a56dd4246bf2aeca16'), + ('\x43d5415bb4e49389ed9bdcc6cf60d7f368016560'), + ('\x43de0396dd47034cbf00630f1b0227b06bf660c7'), + ('\x43dfaf5f88db8a2e7eb6b2b4a24c0a7d2347a378'), + ('\x43e0a73fb58d0ea21b43c57ba7253bb47d35ea62'), + ('\x43e172b6444e5396e59dac529ef4931a7dc32ee5'), + ('\x43e24fad9911cf76091b0aab34ecbb372a18ac35'), + ('\x43e388f61f356d68c428c06638d25e5917a24856'), + ('\x43eeab757e66d7692a7210ab00494abbcf4ebbf5'), + ('\x43eed483eef1fa5f86734c51012ee44ac51fd420'), + ('\x43f415b44af33eb4692bd338e05af8c4b9bc9991'), + ('\x43f9a77c26e8d4a6dee16c5fd80558a251790d95'), + ('\x43fe4b9769b9eef858e8f128af43ebb2c36e3ae8'), + ('\x43ff331b7214085964ae57c999beb2f3224cfef0'), + ('\x43ff6dcedbd2f108d9b73b6615b07234d1b0cd95'), + ('\x4402ccf90547b206dfd597175b40eb731971228c'), + ('\x4407e848c243cc3658edc74a0497b7cb2e4673ac'), + ('\x440a5ef352e320e2ea0f24cc8c3bbaadd3561067'), + ('\x440b22da4de2b1aa56c4ec39a8a1d151d05981cb'), + ('\x44142000723fc70df3866b365a067f9cb983e419'), + ('\x441843ac35f8425b32d60c15f299a416976368b0'), + ('\x441b7eff0505e86634c9593da7f5ea5fafe50f53'), + ('\x441bee6bb2b9cac3382623792f48e80e4bc25fc8'), + ('\x441d3baab4f3bfc1ce251942e420988fac12a8f4'), + ('\x441d7cc68a59af3c2292e262196cbc91c5021386'), + ('\x441e426e7e8556b158dc964edbc45aeb0ac50c9d'), + ('\x442fcae75e86e118e7fed2e94aa43a074a1987dc'), + ('\x4432939c361cf6596f9193733d25c27f846d3344'), + ('\x443ea4034f7eb3db0dd3bda51c468373331bd2e4'), + ('\x44439dfa6865cb38ea1c24315e00331528710c01'), + ('\x444a92452483d084c7c31e2e7243513f6b83c719'), + ('\x444a9e03f991ef78a891fd13cc16665c6e68bb28'), + ('\x444c1a283f1dda74ca832344029768604af3b10c'), + ('\x4453b8f04070b6738f095d38084bdbf69b9d0f8e'), + ('\x445613c85639e63cf88f896a18527ce488b7b1a1'), + ('\x4457e6e6ab34179213f716ab2efd40f070f66d0c'), + ('\x445c1d02fe0fcdb295e64e23309905651a61a6a7'), + ('\x44613d6c73e2822bb04a1c4b064e10f59c0ee34f'), + ('\x4464cdc5101aa74de0cd0a0ccaf3b0a4f426cda8'), + ('\x446637f3ec70ef1a4fa0ab86c56cdf561c245d87'), + ('\x446842d4c6a2c651f8e984b56339bfd34905fee0'), + ('\x446f9f704cbb365a2b869ee8768a89e36785c536'), + ('\x446ff4236967d982e984773a7520e242a3330a44'), + ('\x447523fa863c32669afc8fc226c138002e813051'), + ('\x4478ad21332be3f714ec025c687af449fef4a474'), + ('\x4479e1463dfbadf6152c5610d87233cbfdf086d3'), + ('\x4481591897e0fccc36667e1aea904826d2decaa5'), + ('\x448bed28a63ef81889e9869ef08cd63f4f07e66e'), + ('\x448e9d5a7c495fefee0c8f5846cfe6bf64ceac7d'), + ('\x449941a25a21819b7497f9fa017185f8f85de85f'), + ('\x449c5f922f9aea7276ec25f880abd293278933ac'), + ('\x44a4a30c20329c1c165d489d5a843d5280dfcaf1'), + ('\x44a82526b26b697225b2c6b2bd34343d852efdc7'), + ('\x44b34e7c42e0c757c303e39a43c5f260a7a13008'), + ('\x44b54addea75893bce9ab389ed5e4eab06f64616'), + ('\x44b656b08939152829d10b7d5d8739c1ac617de0'), + ('\x44baaead5e41f8eae6a3b899336fcfeb69bc4757'), + ('\x44baaf2015bb65878878ddc30f3bfa3aab945b7c'), + ('\x44bb2cfab91104139da4ac7e8695699ee1862585'), + ('\x44bc7be830951fed4cf048676b2accde2dd039e7'), + ('\x44bd09392d508da29edd9eee1f508dbceb1a7698'), + ('\x44bee98cfa4cac2ed40105fc412b2f14f62236a0'), + ('\x44c07d97a4137fed5657357535dbf4bdb6a196bb'), + ('\x44c152735bd3959aa9f5aa7ac4735705c546d0fd'), + ('\x44c37a841df1fd493b3b06b49618ff574e83343d'), + ('\x44cae8465c6d51b3994ad1ae28a4af5badbffa94'), + ('\x44cb68f2563b4646b3dbc54522a1884627b540ba'), + ('\x44cb7ed67256875256de967a620a13f28dd56464'), + ('\x44cf41eb3996fd989ad41ce8637dd5db944f1dcc'), + ('\x44d1a36278f14510293b93e46ffd70f08a9ef0d3'), + ('\x44d499ff0a5fec9a680c647758876cf03bc60a2c'), + ('\x44d79d18fd9aaeebb0b26fc4a758975f86488c5c'), + ('\x44d97dd45d766cdc82ed2ec80f1df14b4f72bf8b'), + ('\x44dd427cd2edfe73c82752dd5c48c8cc4f05db13'), + ('\x44e1cd12b6328b7a151ffe420a38890ee1021700'), + ('\x44e3f4686c7a2b99f312f3120f82f63b58d4ea85'), + ('\x44e4d7e0aa77fe1f3db67f68a18e26738ba05ef5'), + ('\x44e662c7c1ec7d5677352bc610164709b81e7f03'), + ('\x44f7f878db0c99ab138275514d2a54958fa52a9c'), + ('\x4502fd5b1f89847fcb201734351cf04a07150bc0'), + ('\x4510163be1abb3bc4336eacfe13d42bf5b3ad5ff'), + ('\x4515900eb66615ef94f357f75e2e6f040dbf04ee'), + ('\x451a8e43503a9abce2045b4c15e9d9ce4126f514'), + ('\x45277b37babd527185cb62cd9737fe1a4cb9c97f'), + ('\x45331b03fee197a2519c5c188a35542c60d91451'), + ('\x4533e8311ba6ca4a810cfe5ede4b83531a51bad4'), + ('\x4538d7f7c78e073e557751d81d2be4fa133340c5'), + ('\x453bbb61b16b27ad14b367b744cd4355b23f69d0'), + ('\x453ebe870818ce80bd40f00f45963d7c940b7000'), + ('\x4543aeea1ba3677655437245cee872f7130eee90'), + ('\x4549324b8ec519493fd6a32d49f94dabbef32ecc'), + ('\x454f819677c1e50ffc4a955fe10b77c6f93b3d8f'), + ('\x45505e6f9cdfea01fb75469a2a19f739e47d11e1'), + ('\x4554a40299b4b68aabc06cc705c2730d0c6e44cd'), + ('\x4558293c8a663f80a1c043fc14f723992eac26a5'), + ('\x455f58e041adf41716740e04630301eb2ecd954d'), + ('\x455f61438a99c4cd6160231600fd13c2c0ce0bf9'), + ('\x4567c29b9141a301e3e8507fed257f20ed277078'), + ('\x456d9e7a23b5c343159d417825a585529b0dc55c'), + ('\x456f0beec270e2fd588f768fff7f2f8d1d6d0c15'), + ('\x4573835f79b1d0dd5074418562a1a08a32512a36'), + ('\x457c81ea3c7c1e346356fc44ac96f3242f76a788'), + ('\x4584181b1fa8f67464b9f89d6e11ebdc5b4a0895'), + ('\x458a4b7a19493980f16b54e5175568b9386c0491'), + ('\x458d4aacd291a4f2f9f82bdf1397899614ca89ed'), + ('\x4590cbaf6a45206dd7b03b6748eac66d4ac4cde0'), + ('\x4591df077c40080c30b07728954db31d7b3edeac'), + ('\x4596fa74a36093031523824e1fd5c574857dd638'), + ('\x459a6c843aadd73af5a711015517ef57a88c81d9'), + ('\x459b2789dc4c7e15343c47e623f3ebb2cfd00e06'), + ('\x459c6b3894d3bbe9a5c1432c0aa78df2d2eb5880'), + ('\x45a0b78ed388a234630ffe869104fe589a57b0fd'), + ('\x45a19572e7d1111ad9118ad6c3f6409996d5d274'), + ('\x45a56a44207ece3f3856a7010cf6625b796a5569'), + ('\x45a91c2caffefb02adae541f8987eca527c100ba'), + ('\x45acc51f6785ce13137b43df4b7d55bc3f67f485'), + ('\x45b102d2496da6b4a1939c7c201aed16d7580290'), + ('\x45b599dc9b16207d5872ad10eedb5389a5a84a08'), + ('\x45bcdb1541c9ddedbe764f41a9930213c9b47d75'), + ('\x45be03998a056919ad9412d086f9768ef29eb605'), + ('\x45bffd8ecaf26cbea339481fe1df2aa63d12f3ef'), + ('\x45c0931c65223abd38d16c274eb9b1d508fa9d43'), + ('\x45c668e2c8cbef14d731424e7bc8a8db7c365b1f'), + ('\x45c9a0f7ec8b6a52353ad9f7042d3501abdd8007'), + ('\x45ca4350e86caac8cbb8709e5c42a2765e250a6f'), + ('\x45cc892f0e164ffa8e50df1b79c7a999a508f46b'), + ('\x45cebc6c9004fb451696033de1fe42efb626c0e2'), + ('\x45d20857deaedc5806067db7667e266dd2f13ba3'), + ('\x45d69109d52e81eca73cf93b07dad23c0841a2cd'), + ('\x45d972c143c17906b68d45b2e9e47c9ecca71605'), + ('\x45db78e86f3fa145bc99c63fb019c8d719cb939c'), + ('\x45e342341757a4598d42ebd5f797492c7f502e82'), + ('\x45e720af32f97e7bdc5232e2b84b61329e3e6026'), + ('\x45e9886f3281e69a0989cab0ae08a871de286416'), + ('\x45ec20f93202dbb2f462edcf130b1e830aa5c3b3'), + ('\x45f8717ec216590ea78555672bb17c9178ff5c29'), + ('\x45fa6af28824f0324ad317aa8e88dc560b064e1a'), + ('\x45ff6f1418f8c35411b2d6b0f5dcb213c4751547'), + ('\x4603fdc74d8c48806644d90037c932377a4d02e3'), + ('\x460552795ffefe47436e2daa1481783fbf46bc45'), + ('\x460567933afe0622c1541409a9279bd524632d9f'), + ('\x46090b3d01871dc61fe08f3c439809b863232d58'), + ('\x460e9590e87b4a8d4b1ae6d015c0dde496f793d6'), + ('\x46113f520b08be862bc3d01bbdfecf88e9092ac6'), + ('\x46123d4e9eeb2680c67bbc8d772c4338145f7d51'), + ('\x4615bb5ac12151c8d7b9cb5c8830e67dd08c153d'), + ('\x461b7a89c40a294358a1bef3eeb33edccf604b4e'), + ('\x461c26768feac65dbe7c8c4e8c2022a6b3663a25'), + ('\x461e44d22efa396234ce0697209a608c288daaac'), + ('\x4621c85c2e3b514bd5e387e2136f7cb3bac14dec'), + ('\x4624dc8abe6bdddc438171d9a8614236a60b50b5'), + ('\x463c6a6bef7b2936eed16031f3eef99d5e2af81e'), + ('\x463fa9559032a5f422dd90fe6d79c83b419d1f92'), + ('\x4644162847de01dd13af79bf8e7a5df9cb216a75'), + ('\x464692783dfaa8b828b38a51fe7a5bd1f620387a'), + ('\x464d57b42f23b47109bb4d095368ec63c198ad53'), + ('\x464e83ccbf6936779450442030db5d81dbcbe686'), + ('\x4650bee34028d880f084056f4eb3bfe64d042ddf'), + ('\x4651cb0d290c597aea5e2157979ed8568e04ab49'), + ('\x465546b64813b8f4ce4ebbd01230c7a802a83bb2'), + ('\x4657a3e5186befebf36d4d80c702a008b23d87a8'), + ('\x4658abf71bd196b619b3b22180d99e456428a152'), + ('\x465e266968b989ce21aa27e54aaefcfabf437a48'), + ('\x466221ff79cb7200cd9417b61ae060f9db4ed4f2'), + ('\x466825813af3a76d79e2bacb5eda7710fa00d798'), + ('\x4669e8f447fa36dbf342467b55957082cfe6f848'), + ('\x466feb3b492af47f740f11742428fe5f5849cb5f'), + ('\x4677d900c6533362be418e5c6d4b7a7291dae514'), + ('\x467da83a5ec9c3a3ed986d4f43b12842aa1c5519'), + ('\x4683ccf49a0c36a39915675110325a8d2e61af72'), + ('\x4683fc7b381f3184c81b014b1a716f123dc865ed'), + ('\x46898bcb4e4cf7f80ebf48733441036e2858d4cf'), + ('\x468aa453debfbed8d555539d1f05afe78f3855bf'), + ('\x4690454a75f13339d2eb2f501317445c7ead0f3c'), + ('\x46918f074569b10d6490dd7ad8a9f376efd4b067'), + ('\x469359ed9ca5899ce30354736e275764a8ca3359'), + ('\x4695cfe1b3478510e9ea222bbdcf637c838fca73'), + ('\x4697145a775620c9397855b0595f839f2f235bff'), + ('\x469973c987146ee1c3af3c87da60b99eb70e649e'), + ('\x46a0712ae6e2b2195ce933b162dbe7756338fef5'), + ('\x46ac51f98a28cd63cd2eea0a5fe91877671b03c6'), + ('\x46b0f2018b90a4a0abd409adde02231e7230c15c'), + ('\x46b657fd74f5e4d3f7402788f26d01fc057f9f55'), + ('\x46b69be83f9117354dd54941fb2aca023ff9c79f'), + ('\x46b6d242f072b3c687e5f746b1484cdc75ee64ad'), + ('\x46bb033e99aa9ebb9302ea1bb54dd791a820fecb'), + ('\x46bcf63f5963fc91778e1b1a5fbe6d9695523d10'), + ('\x46c8d2d70dfa8e1eb95efc60456f998ee31b55a1'), + ('\x46c9ed5c53fab4930f22710c88dd4b0d2fca27e7'), + ('\x46ca0f020070a7d5ca4d7601c5a93de6e9ecf7aa'), + ('\x46cc001925c28336e5b8b82004b4505a800810ad'), + ('\x46d3017b65df0bfe01a99b74541745dffae2a392'), + ('\x46d68cbddfd49b1b6028a80735be212d50082fa2'), + ('\x46dc199a1c6470e2f24a82cb792b413efbe93961'), + ('\x46de78328048c505d278d95e632975c4c9cbe548'), + ('\x46dfcd448f468dc68a33c5b55b906aa12e83d151'), + ('\x46e00f142231907b2ff49f0bfa85f45ad790acd8'), + ('\x46e0ee70ff2bc17b5f9819f3133a8d3a0c637c9b'), + ('\x46e2b000daf69e0043f1314621f24a523ddf1379'), + ('\x46e6530a51b76aa890b33e2eed5d12e9f909611b'), + ('\x46e744c84e13f46a4e99eb017e137917db4b8e31'), + ('\x46e763cd5c046b81e6036475e246ba45444c29db'), + ('\x46eb2e503b8dafc5a4b706ca373a35cfffaeb6e1'), + ('\x46f380b1b8d343ce916183086cbe67e464fcef58'), + ('\x4711dd7e5efbd0e93cc218be97648c3a1f8a0837'), + ('\x4712d7fdbf81e5abbf03e2f65427878b4587b988'), + ('\x4713707be6827a02200d48b3fcc5ff34f103bd0b'), + ('\x4719e4957a4206735da1caa98dfbfc999c28c939'), + ('\x472678528a119361c730bd787de50bfb34201bed'), + ('\x47289c09bf4c1deaef48b69e40c2dd44e7e78977'), + ('\x4729628f049c563007d6122845b75c5c49eab163'), + ('\x472c62b586a2ae441e6bed385154c109a567c35b'), + ('\x472f9b9a6f256783d6095b8ad66e31166ca94a15'), + ('\x47312c965845eaaa3a9907bb9283af24ca500409'), + ('\x4731ffd03d2773e2d27325e1a4f4edda3adaa380'), + ('\x473735f94aef477b66b4e360b13fa0780ccebf76'), + ('\x474401a82b328f87e33b3c14a9d492c3a1b1e42c'), + ('\x4746cf13f706b5f9554dd55127424b5e67673899'), + ('\x474bbd7c94913754830f58ee297508630d7cf622'), + ('\x47552994b65181f2facafdce91611c57c20d595c'), + ('\x475aced68b573dff997cd8cedfb32fe813753a93'), + ('\x475c811b26d971c2987014a2a2a4cb05586c98f4'), + ('\x475e681e1c5bb89bddc8395d6ff9ef81e81f8fc1'), + ('\x475ffb9b33aada3495153862919417342c4ff226'), + ('\x4764bbc9d589774e2593dd453d3d08f666618466'), + ('\x476a10c7638f3dd995bc96526ed9676c74b88629'), + ('\x476b836d791905aaeaad3a0976b1080b3f281fec'), + ('\x476d6d9326c029846a662db489a25b4a05ee1117'), + ('\x47720308fcd5e34992932f26c1c8cff268777117'), + ('\x47751d67e99fa43d3a3fe12f2b998ac43ed4dff5'), + ('\x477a54911bea0ed06889705a17d5d29f07a6a2b5'), + ('\x477a80695ad0a99fb3bdc2efe830bb92dece692a'), + ('\x477cb88bb5647b4673665f594e1c3c852863406e'), + ('\x477d4f166694289c01bb5d7c93ed1d23ac666b73'), + ('\x477eabefd4b516f8419ba27d260e4317eb4b324d'), + ('\x478013a6dfd7cbe4549d71cafb48bd77a73ac403'), + ('\x47871173e1db288fa304570aa81c33a701c05bcd'), + ('\x478dd4d777fb3b46f10e261edc3aab965897a427'), + ('\x478e62ae911d641ab93c0b68ca2832cf21b22b23'), + ('\x4793052047625303c8fc59bc26c23fd2ae88ff26'), + ('\x479994442da0d8cd2ec3b0e684997d7aa94afa17'), + ('\x47a7234744f304c959e8b6c766756b27293e0e7b'), + ('\x47ae57d1d40ffbcdcd29d0c9e576839de73a86a4'), + ('\x47b4bcfb70f691b3d835cea1597f2d00fad02308'), + ('\x47b552fdb2a301ce0cdd27a9679d9f1ac22a2112'), + ('\x47b6082169aea1ef05aff6953d0c79ea2779d611'), + ('\x47b88070438ad56f0ab3f53807066bc4d5b39379'), + ('\x47bdf359e3feb3180e466622e52db3fd735d7c9b'), + ('\x47c04f12817c0dcfec4390acd518965c9b4ebb00'), + ('\x47c49fcfbb02fd6450f1498e022c73f77c3f8804'), + ('\x47d4ef17af861c9745328a9c0694f85f667a2a61'), + ('\x47ddcf7c9feefd3beca774a14eca4e3286f4d255'), + ('\x47e65b247d93f8218176b746837c8c34cd9b3b49'), + ('\x47e69ab7f685efac7f28f5dcd6d6b769e86af314'), + ('\x47e85803d71d71e30054d5fd30f3b9af84bbbbf8'), + ('\x47ea169c315d86326f0b959c2328baf20c5a6f31'), + ('\x47f29356fe44534c361b10cec858072dc5a7edef'), + ('\x47f718b38e2fac29360031ab60aa751c724fe4c8'), + ('\x47fe0d262a3ca2122cdef6cc87a63a5b2335ff52'), + ('\x4801d77cecbc127f59a217be2524ff5cdecb3dc6'), + ('\x4803f753575c4be6213302182c5748a4c4189559'), + ('\x48081d5e76e4adfd92a849a8cf86de2779fb495f'), + ('\x48086321d6bb4b147c0c67734bbadbbe108acc2f'), + ('\x480a63e92ab3d38fa9f514dfa342f640b00db7cc'), + ('\x480c0cdade73de872a0af5dc7acf4430acf40cc7'), + ('\x480e36f810e85fdff95c7368030e3dc9419e8103'), + ('\x48112c4823e20ea25d29a128a9d860e2403c2b5f'), + ('\x481fecf063797e9b02827c83909b10d47dd9a959'), + ('\x48237509e3c8b97f14c69a431d9b43dcce6c1bc7'), + ('\x4827d8c4b0c113b772d8bfd6f3f0acd4b96c703c'), + ('\x4828d984b20c3e05f55e49b25bff4a6535bf81fb'), + ('\x4830edba49f0739f210ef413179da5876d712802'), + ('\x483b08f161d13f28bc4ea076a1879978ae5fb8bb'), + ('\x483b5631f7fe45db3ae8eb2439b3b4b221375697'), + ('\x483bc8531d4f15ecea108dea896f67dd4120d0d7'), + ('\x484ab6ced89612cc1afe2702f164c74d04669c81'), + ('\x484f26cf4b30b075e54357241bd7b63971e74381'), + ('\x48566463abae2d9a258a45aeecaad589924f4167'), + ('\x485ac87489040ef195638d7337fc63c1a81bec8e'), + ('\x485e5f56be9204181dddd1142a58df3fe8317ebb'), + ('\x486d502769ac213f04b108ef31413bd862f9a8b3'), + ('\x4871498ac32813bd044c66db1ff317b556c53b3c'), + ('\x487ba60d7e653f07c33b3733f51a389f114d5c2b'), + ('\x487df53f12cb6e3f8075fd3380fac8d557a716d9'), + ('\x487e2d40853d9ddcd29e370836594830231bb135'), + ('\x488cba5ebfc2931a39093b5c99edd98a936f7159'), + ('\x48915cf6934059b484902b9d6d1651899404e4d9'), + ('\x489b93069f20d00a608d29109b547dc1f48a2bbe'), + ('\x489c766fcd508430dc603790bd64526ec69eb6f3'), + ('\x489f60c4e70ac3d904348988dd1dfc5840521b73'), + ('\x48a2d0771181ad5add88ca51cab3f3149a4f19df'), + ('\x48a53c0e4f40c3c18236517beda0e20093722296'), + ('\x48a60e9810d281ff2d6eb0c19e21aec942036283'), + ('\x48a7fe7d9e0cbb561c5dc2d8a19363f6d7ffd866'), + ('\x48a86482d053935e7548131fa792046141926c08'), + ('\x48a94e67d3cbd1b650c45e0b868c89c6c333ffb8'), + ('\x48ab957f481581a47aa4f65361749fe75bef5b27'), + ('\x48aeabac817fe34de993a8ca1615ee8f0a364f83'), + ('\x48b06bfd35da6114f6c2f4ac4c9ab7bbd29be365'), + ('\x48b4d57a8c031e0887db2fa5247e1b3bc1889044'), + ('\x48b510d1f0e31dc194f4ddc32c36a7c4495bda9e'), + ('\x48b5e973e0238f8cf29a6cd9dd9fcd38eb57265f'), + ('\x48b74e2a864814a8598d76abfb209ffa37051366'), + ('\x48b93e1b9c1488aa7fed1dc826174442083e7c66'), + ('\x48bd8694c66cb678f8eaea983cc06ba4a06905e5'), + ('\x48c1ef7bb521f79c9cfca0d37dd72925b56bcc7b'), + ('\x48cb37fb6ee1bf142dc58737c3a83b002eaea56b'), + ('\x48d0ec181a8ad99a970f3d9922321e88c23effe0'), + ('\x48d2520b7077e52201809c5e974ac6bf2685eb7b'), + ('\x48d3771c37d56120f445ed91946ed7242ad8018c'), + ('\x48dc043a7814c8ecac9cfbc5f6d6d67b8f9cb0fd'), + ('\x48dd63534bae7019b7a4e48ec648e29ce153ccf0'), + ('\x48dfc3da6a57f14c9e1e27bb004553668c621a03'), + ('\x48e302df44c2b57658d36de30b8f5954f7c42715'), + ('\x48e5b43e73a1fd5266df138f60226432ed988197'), + ('\x48e81ead623c141ad32a575a701ddc81b6d94464'), + ('\x48e9fb8acd70a1175c5e17e2d105bf6c7c891924'), + ('\x48eac164cb3c867064fa025ef7baccc95715c769'), + ('\x48ec212b3f5ce617685be678519e69d14da465b3'), + ('\x48ec5c1e0a471c385c8ec623109b4907a50a0839'), + ('\x48f5735a9761095e23de38443595859a9923c8a4'), + ('\x48f81ff45bb1b3d2032eb7be0019c76f693fa90d'), + ('\x48f92d25a628f5338f54e5c16afe934b3493fce2'), + ('\x48fe8e9f51cd834403552f98a2f5eaaa9d905c7b'), + ('\x48ff0fb3d07a8a8bfd846171c0f0b17945ce32e1'), + ('\x48ff8247a3ee533179904b9d2f04f59a81f9a10e'), + ('\x4904232adabde9e135aa1e956df26b9bb9a7c994'), + ('\x49062e14bd14e4aeef4cfbf92b24843e8262860b'), + ('\x490651e9cee421d5dc868b0e698fd64289007430'), + ('\x490c225b34d233ff4fec90c7fcbf24b951cfc2a1'), + ('\x4910556ba585e9056935c4a120812a413236927e'), + ('\x4911a8dee9abf4dfd65609e771a759d9d81290ca'), + ('\x4915fa24d7c25f6344efe96d5189e233aa91b62b'), + ('\x4916fc4fa721e432647a9101af8f61d731908a01'), + ('\x4919f5fe2b831e49d8abbf7392390b6ce69905f7'), + ('\x4919fde587c7b9dc5f11e6ab76a69304164c8d84'), + ('\x4925404e9fb8a24d58e353687bb833557ae36b2f'), + ('\x49265bb01302b40cfaf5104551d6d3505d7ef126'), + ('\x493528ea854ecf77ac8a0bfa58615f6e9d67cfd5'), + ('\x493d586926dd03304238a2da3d04724294823163'), + ('\x493e1f96ff954d50f4ddeba8bd74e9f2867746af'), + ('\x493ff8f9c4816a826e1660ee0a4b4dfa2c219cf8'), + ('\x4940ad6ced654981cae569a16e81c9d22f562d6c'), + ('\x494205675887629600a2db80a41019a15cd0adc4'), + ('\x4945ea3d57b2ae769ac2fc1f04ef7acb4fa64c3b'), + ('\x494844852d357a4c376f9a6dea79a3e481734f41'), + ('\x494aa56599165b65182f2ac0af94400c9c7a57b3'), + ('\x494cde11c95a7c078500c5fa7081abe151fa5c20'), + ('\x49648cc98f5ccf8864e8997c876007c36f801370'), + ('\x497130798c813c0f279cd3361d3df23a81e41881'), + ('\x497465654e22c230c13ff5d11dd97ccec812e472'), + ('\x49754c89e3689d752de172cb2822c797db5731e4'), + ('\x4978e77389294735d53fb2b8bb0f8b77a4acb37a'), + ('\x497b14e96426715986b4f2143e43918495ce2881'), + ('\x498485b77fb9548e71bcfd5aef1259304fd2e9a2'), + ('\x498ac1b246f53b8da5e9919819d9e80d6a099590'), + ('\x499381f0feb2550a5249930f646cc7a893af4a48'), + ('\x4994755e6864e26b9987a0de03c2a1997278fc3e'), + ('\x49962dcc5dfbab54e70acd142ad9933683e6dfa3'), + ('\x499bfef5b53a30a4e326a08755df4213598af7fa'), + ('\x499d8bac1aecfc2a8fcdf8f6d30aae9dac8c6afd'), + ('\x49a3fab61c583d03fdf3c0e1378af35140fc8b3e'), + ('\x49a47ed793bd69ea335939966b53b57d9f887c90'), + ('\x49a8286f8c1fdbd8c6f639667ab1d676d7ad634f'), + ('\x49abe1aa6bb0faad06eb4988b415ba0979c78164'), + ('\x49b202fba7737635dd5ba9b4da06bc8121effbd9'), + ('\x49b2c1bb0c095d8060d5e66911fd39bbb7972989'), + ('\x49b54c740644d8701d25f0d5480a4d3fdb7f59bb'), + ('\x49b5adf0b68e58762f44e9a51e00cfac4a8a6fd0'), + ('\x49b5f42ca52c874c2fa8d4b6319740a3de75f4ea'), + ('\x49c36a7e2f5a8063ea54a3469bc154d408689911'), + ('\x49c4eab954b5f0d2e9abbbe1034921d212d260af'), + ('\x49c597554077c3c39ec5f93eb79206a63f0d7e75'), + ('\x49ca1408cee4c83ce33caf209754eada3c36b8b5'), + ('\x49d1ba5cc8df1bfb7059e4dcac09ed3bea292768'), + ('\x49d5059de2cf0e94f164dd0662ed9ebc0db23483'), + ('\x49d7c87a4502dafb2e3362635437d147a8191341'), + ('\x49de394c3eeab871b944b084bd865cb4e2fa28fe'), + ('\x49de64f206707c22d4061300d3f8d79432bd9c7f'), + ('\x49dfd0541a57edc608698fc3ed2410e2e1b3782a'), + ('\x49e03ff048e6974c7e5cbb60023d2525c5bf7d4a'), + ('\x49e4becf6624ed794659a7e5418da51d0c9e8a9c'), + ('\x49ea226fcef42a5ccebd7994836da0485ae97d0c'), + ('\x49eab8f426e6ef9ca7853572c7ed756365e7766e'), + ('\x49ebaa6008e9578c5ee1ddd7439fa91c7fd5c50f'), + ('\x49ebd1c93d0d43dd2c30002ea93bf6dd511bc7e5'), + ('\x49ed5917553c06e8b141645da2280f25057a3aaa'), + ('\x49f272f828c17c9f52b64c27fa6a3c399bee9344'), + ('\x49f8647d209dd3e0af1c37fd403b38a699c3e4cf'), + ('\x49ff583c0436b7c593ba66ba896cbebb4673825a'), + ('\x4a115b6b7f2a59a45a9bff3b6882337f93ab66e2'), + ('\x4a1404dee37a18ae35b35069f9854355271f69ff'), + ('\x4a1c009950f19531512546fe08232e69ae61e7e3'), + ('\x4a1c93d189c14fbb8cc511d4f16613d64510aacc'), + ('\x4a2b971502c62a6e708a873b807882091226e8b3'), + ('\x4a2d32071cc900cfce893eabfc2fcd3820a7d3f3'), + ('\x4a2e7b8e7c8b8c1fca55fc87d3ce004556b5419b'), + ('\x4a4ab9bd2a43c2127e85f46403d64aa88d21b057'), + ('\x4a4d1b3a9eea865385be210847682beb5d36b6b4'), + ('\x4a515b5908c33085cab073ecd8f7479953ce48dd'), + ('\x4a5980d36dd0fa7dd087301fc72f9404feac96c5'), + ('\x4a5b780f1f5c5909b9f9ec4fcce0538ba508b419'), + ('\x4a5d654aa311839ec2b256dc44aff6af881bc819'), + ('\x4a5f8ddc9ef3ccde045fbfca0606f7415f6cbee7'), + ('\x4a65d428959394795a83d5dba4963ce472d038d5'), + ('\x4a687b2111ee2d74a04499410e4a957024e62cf1'), + ('\x4a6e67cb530120cff2329971c47d09663a8e9ad5'), + ('\x4a74a9d483127e0613b5272c37ee592472a3b0de'), + ('\x4a75b1b74fc3a71cc53f6a3722c617ca336b1c88'), + ('\x4a7acbf8ea14d1b72033de02bf15c3ee50a57e82'), + ('\x4a7dd208af15aac9195691de0bd061b6b2ec7372'), + ('\x4a7f3d77069b176623d52c1d3eb468efba5562d8'), + ('\x4a83d9c872f8ae3122583879345f1dd8f2312882'), + ('\x4a88cc22f2bf99710b505b9dfb617d238c15bc4e'), + ('\x4a8de5a9a550063079c88f3277c577a63c170e6a'), + ('\x4a8eafd78157a39cc544358cbf424e2303157c1b'), + ('\x4a90a7cf551c79792e5b60fc2afdf7cd5a00370c'), + ('\x4a91538f7364976bab6f4bf9f205c23c7bf230e4'), + ('\x4a92045d6a2c49febf3c3938c763658061b6f642'), + ('\x4a970c33899f7fc5f53796df7dd99545fb080e8b'), + ('\x4a9e147b26396be6d6f216cb499e53d39ff41e14'), + ('\x4aa122d16c491f346ca22797d0364f8f329122c0'), + ('\x4aa281583884552039d13e03e60a5c1913e2bd6a'), + ('\x4aa2cac506ca2aca92b3c5d5522c1a2f7b8596e9'), + ('\x4aa57d29f5ff415097ce416d371d67eb98bec6d9'), + ('\x4aa80a119791b2e33d6fa7dd92220ca192cee29f'), + ('\x4aaa76dc509a01c2a6558ba7e161ffe80f671a2f'), + ('\x4aae4197cd1d080ce4dc7a271f9619058fbe829b'), + ('\x4ab05bbd8e36279778588944eaf06514b8cc07a9'), + ('\x4ab13681a9a2278ff9d67e81d01fd4217c87b27a'), + ('\x4ab27dc43a4f27a05fbfba51aec9a26e49ea1985'), + ('\x4ab48fb073f67ed2b0ceba3e2e0e7eefcb4dfda5'), + ('\x4abd9a552c61fc65f5b2580ee1ba784a56c689e9'), + ('\x4ac38c079b38a9f6fc6fbf6f89b409c1d875038c'), + ('\x4ac757dba5e31b0324a1b009d40217abf2d7711b'), + ('\x4ac794817f5d8fda27a00443d18a54c51f0bc5fb'), + ('\x4aca1319a6e6bdfdef757925c38e3feb5e6f9524'), + ('\x4acb217d3c676d8fef0d63e6b7aad7e9a256a6cf'), + ('\x4acbf2d33d1afa8973b0401b7f21080cb735369c'), + ('\x4ace95d036ade35afe18549e38833853b1ac7d91'), + ('\x4ad31b578eb630bb1b1a6d6854f330431fd89583'), + ('\x4ad6db6ac9fd0583c282ca296949854bbdc1b527'), + ('\x4ad91bcda5b945d45944faa40cb15da93b8bc59b'), + ('\x4add785dafe43f11202c6b7286b47c3b62401dd2'), + ('\x4ade93b687194ea75407fac30d8916693a8be68a'), + ('\x4adfc7abd9b834939babf9f543578dfcdd46f07d'), + ('\x4ae08dcd29b009c8bfaa945babef155007525614'), + ('\x4ae946ed6d8b0572c8fc19c576dfc3215365e6d5'), + ('\x4af541099ee5fbb608b78267c500f1a779572d6a'), + ('\x4afa4039f0a511dd39f63b8a40c2e69929f9d624'), + ('\x4b060207f9ef5287fcd9a26dda8ffa278c02be66'), + ('\x4b18aed74f10f7ae062a86ab861b9e76c07141d8'), + ('\x4b18c07a71e12ee9c270fed2104b61f311816a1a'), + ('\x4b1f70f91f9fb144e52839f72aa03a5efefd5d4f'), + ('\x4b20298cf4ff1d32f29ccf7b8fa8ee5dc165f0ae'), + ('\x4b28d0cb1a43f4dcb2271f3f74bb3efd987f7168'), + ('\x4b2d339bdbea4b805a6a6b002458c51e38ca723c'), + ('\x4b34034ae97b33c832ead09d8bd9d00f7abb03dd'), + ('\x4b37208c1c8f69a9af49081dcd9d1a9150c4cdd9'), + ('\x4b3a69ff2f2be35f4ceccf555ab59993e12a226b'), + ('\x4b3cec95440036174562821423419e697f218988'), + ('\x4b421def9f6bd0002890be2daf6aae47e6533b71'), + ('\x4b45d4e83385562a28abcfd4206de73e36c7e0fb'), + ('\x4b5228785e1f124c533cbd444c109dc43c213756'), + ('\x4b5f152cca073ae8a07f883d1f7b0bc4cbe6726b'), + ('\x4b63689af1266a2c839a3f75c01b8763c2f9965a'), + ('\x4b679d7ea695950284c75db09cb4a3f568f2ceb9'), + ('\x4b6bc9ce71f931d9453d52b6494d3d825a66722b'), + ('\x4b7036095b0aa95bdb3eb2592ffc28e0fad47951'), + ('\x4b720782a7e203c16039cc4b4552143f33e61524'), + ('\x4b7376d11ddeb62d229ed6857b2e0a0de34d6aec'), + ('\x4b7e17d277552d376085ee81a75df539157546eb'), + ('\x4b80fb2fd88d3e2b0f4059ad3c814085ae59bdf9'), + ('\x4b879b2ae63536b1155e47b1722e7bac1cbb3ea0'), + ('\x4b8aa13c68e566da6feedfed733a662aaf128bc3'), + ('\x4b90be1beb7ab6b3248e313cae2ce01fbf5603e6'), + ('\x4ba0a23ac3d5a4595d3af08ec1be6843dc9836be'), + ('\x4ba0d5a9b4232adcad7157a60e9185054e59af83'), + ('\x4bb2b2da54ebb6fb0303ef2fadc256f49d59e8b0'), + ('\x4bb9ef6e6b58cad96ccc83d1d2988d67a1129e58'), + ('\x4bc1d5215693b45a94cbb4c6868e924c00d4d8e0'), + ('\x4bcad6df0cc0d8c09cf806ccb95173a85c0482c3'), + ('\x4bcdcf650e62389a56d2637463f39bcbaafb799c'), + ('\x4bd1fee0bbbdf892e4b7ac80ee2ed0ee7038df8b'), + ('\x4bd32d115f0279e38abad441f104022bc5adc366'), + ('\x4bd51c5d2d6bf2654ce2638d3bb857856c0178b1'), + ('\x4bd58a690d186dfb60e8c6cdbf512d3deb6b4cf7'), + ('\x4bd5932521efd0d70c6bcf7d0d1e90a87012c770'), + ('\x4bd852fa0080b345c9c5621e2a163785cd1075bb'), + ('\x4bda2c58f3ba81387e32a840f53298c9de245b86'), + ('\x4bdcbe405e7cec83d94543461c2de4286795f810'), + ('\x4bdcc46e20ad6e0602c9b9feca2fed3023116d04'), + ('\x4be1acc39689e0299cc6039421ead24eeac5d7cd'), + ('\x4be323eea0e4165d8ad62eabb4fc10a4c310ff2f'), + ('\x4bed67a84e1db8a1d842764a18caf3f328bdf4e7'), + ('\x4bf44aa898098447939c7df641af0462e81f9315'), + ('\x4bfe69f286929b26ebdddc10c4eb46d02d6f6d3c'), + ('\x4c0683a119b2df9ce460a233fc59f708e7316c29'), + ('\x4c089050ea03081c4ea3fc1c9b82823e88f35d99'), + ('\x4c0a246f56764c849fff16420b021dbadd19d7ff'), + ('\x4c12f62d949c5313fee1e3c24398edde1609d0dc'), + ('\x4c14baf9204207a3433410ae4c03a8de530b92ec'), + ('\x4c153602e0d80550f6634de1b7a147d76c099f39'), + ('\x4c1c3009fd0f58bb2f48481cad4f2ba5aca57442'), + ('\x4c1c4625e22c21840585e3f6dec9c4213dba3890'), + ('\x4c1f6861fa70565584f37f60f81a5f4066ef5b20'), + ('\x4c2230385381218a8c09add61a41a1c08158e24e'), + ('\x4c248723f16dd3ce2ca8ebf9b9e183cf48fe9d79'), + ('\x4c2818123a3525efc4094d44c2b0f6cf4e35d535'), + ('\x4c2e3efdacd245ba7045d781513246fdb1492e51'), + ('\x4c3247e81bed7a607f18f6f23b8158e02bd8882c'), + ('\x4c38e20ebb14685735cf52b05f62d018d8b85a03'), + ('\x4c3e3ed5e7b0b9ae16620244e4b57c77d94af6da'), + ('\x4c4843ba2ad1800a5caccbf1dc6321ca88c2bb33'), + ('\x4c4b6e616cc1061b4e5402f09db6970293c43262'), + ('\x4c4bf3bb1aea4a7c4631095a5d3489899bcbe137'), + ('\x4c53782de7e9f95b27930f9be686be7db7775564'), + ('\x4c53ece35caf86eb6923ecdf7c650e42e384891b'), + ('\x4c544f32b9687a03c89141d209af20d1c979855f'), + ('\x4c558d3425e5c1c2868035101bebeb6857cbfb2e'), + ('\x4c562dcf9db9cd1ae97a7977c056ba8c8e141874'), + ('\x4c5ab8b87190d36fd752694f76e9e175e1973ac8'), + ('\x4c609733d39f451ff7d60c3050cd45c887ff8de5'), + ('\x4c638531e6463e06a52acb2b3bf5058148e4f36f'), + ('\x4c665e8eac8409d4c3b8a5382874fcc91abfb26f'), + ('\x4c71e611694b92f69f8f72021aaaf3ae704ff3d7'), + ('\x4c732c6097636ce9c298dac14cd55289b2185f98'), + ('\x4c734928a46e47188a92195f40b003612adca272'), + ('\x4c73de49e7843254a6d0c41ea127362361672302'), + ('\x4c7505a57ff5270c4ab9fb2572b80cb4b7865fc6'), + ('\x4c79bb3e9a5a8b1b8a1785ff5ad648ec7d20646b'), + ('\x4c7f080b15e9a4a00fbf8057f86115fad67d6d27'), + ('\x4c7fa0c212e6ee46f455cf465f3833076f8077eb'), + ('\x4c858d401ad72117f18a29a9b5b9348ee7a51aba'), + ('\x4c860807ac8122b4fbbe8282b6ef72ad32642813'), + ('\x4c8ca867734eb44c35bebf96d4b7a48d4c029fbb'), + ('\x4c90043c765ff393ab66860ec6b9c72fcace0a8b'), + ('\x4c9166712d52b781a033a89b3986907d75aaefe4'), + ('\x4c931095aed3103f59e9971ab196a853db8a0e50'), + ('\x4c9396c92413093004fb3e1cc2ac05ddbbc938ea'), + ('\x4c94009f7af4f0ef3193e48ed7047f0e1ea799bd'), + ('\x4ca0c00ebb0b95782317515419599fdf9847d402'), + ('\x4ca1dc80e1cb55cc80d2ac522a3a7a25a53ba01f'), + ('\x4ca89d5e01452583e9328107480f9c934d57ae1d'), + ('\x4cc1fb0cfb2a6a9880b06e588ecf361c94cc743e'), + ('\x4cc2f6712c0436f8ccf22a20272831d68faf1d87'), + ('\x4cc4d885f603903b5bda616668bdd0c59aa3e406'), + ('\x4cc6da8e8706e700730bf24bebc7033403041edc'), + ('\x4cc71958744d4fc791b6861c333378577103bf5f'), + ('\x4cd864d2bc32fc55a09591be0e89b1413e3ad42b'), + ('\x4cdee9f3ce46735e4a2db4749afaf5e595cb1e33'), + ('\x4cdf71ce36a418a9de6a81725cc5fccd27171020'), + ('\x4ce6bd726fd604396ff7cc5bf720f815fbf2727a'), + ('\x4cf11195061b486b30b224e7015f54fcd02b49b4'), + ('\x4cf3b9ca3f42d0d97c855728dae43241d25e75d8'), + ('\x4cf6552b9ca119903b0a4853e5c4438c13c64952'), + ('\x4cf7369701dcf38659071b79edc67414c4abea84'), + ('\x4cfc922d9dc83d732976d049e5393211ce3f1a7a'), + ('\x4d007066ecafd4498158435af348a04833ab85b5'), + ('\x4d08f96c020d636f6e3a8908813c502f490895f8'), + ('\x4d0ac08c04d75f89804ded785b075eb25d9c2766'), + ('\x4d0d8b61f8b5fa02a7037fb6c031d01958739226'), + ('\x4d0de96bc972e139e49b76ad03b932421d51da16'), + ('\x4d0fb9ed61165f022a9591c5c23f855f9d0affae'), + ('\x4d17824408c8924262e11d751e9c6d7a650caba5'), + ('\x4d1a2f45ef06fece4745433f56639848dc0d3d8c'), + ('\x4d2652361fbeb898cb927d010e5ea34d1623a8cc'), + ('\x4d26cff4d42b42de5f25673a13df73b791c76266'), + ('\x4d2a24d48af778ebfad7050d7c5492f7dcba7de7'), + ('\x4d38cf4563f2d6780f477a146349b4e8759e72fd'), + ('\x4d435b5aa45e7a532a53057991040fc713cd762e'), + ('\x4d447177b8f668f343408e6e03b64aa7e41a1496'), + ('\x4d45a77fae57fad93c88006cd3c9d51da2897961'), + ('\x4d4916b44193f636a1f9c9024e0d6df7a348d9f9'), + ('\x4d495920f80dc44eca80d7cfdfa21d0ddbff80d8'), + ('\x4d4c28e0efd32f8f8313ff0e6660ba1eb769e771'), + ('\x4d4f656abf20d9879c33ed258f245d50b365f3d5'), + ('\x4d533c891b7f522c438361f24b3be28eec81d738'), + ('\x4d5ade9810b4b0fcd94168b19013cfdf2f2ada0a'), + ('\x4d64f6441617595e88cb1b7b1e9bb041f06ee37b'), + ('\x4d656efe453dc4f9b471815608e23c7a42a19d3c'), + ('\x4d663b6112bfe549ac9814bd367f3644c128e036'), + ('\x4d69c09eebe1ca268ab917c6728a5c331f31ae6c'), + ('\x4d714e1566ba86ede041a149353a66c4415151e9'), + ('\x4d73330a1a97e070a04b940aed562bab167fbac3'), + ('\x4d82728733d9cefbf10f14c9afcbade0095d65b1'), + ('\x4d874f985cd997cba19640828e12a6c5baea2d32'), + ('\x4d885e87d57736cda7ea2fbf7e4a6a437983d4cd'), + ('\x4d8a1ef67477bbf2355afb632fb5d0b74b056753'), + ('\x4d8d794ecc22511daca75d8e2e1fd1d1b8a3675a'), + ('\x4d8f70c67986a88a63ae386393d7eab50486dacf'), + ('\x4d924bf5f43fb822c972cc2f8b2b22a158880b0c'), + ('\x4da2524d09e58bdb46075b6a5e6b4aa7d9045b28'), + ('\x4da638ab1f6e223b2bcbbdeaa028bcf852b71a3d'), + ('\x4da6b3a896d62ac898aba023e91e155b2449b03f'), + ('\x4da7b5c0565c0f18281850bb8a2efaedf446bd78'), + ('\x4dac27766fbec070b95db6cfc20de89a820dadd3'), + ('\x4db9b5af292e9840b7e250a9e55b50dc35a051b7'), + ('\x4dbb0788744fb133773ce0246d638892b7b0b095'), + ('\x4dc1c9f912d01c6abc36e746b9e464aa60912b8c'), + ('\x4dc3d05dbf34dfa14e60247a9faba2c033e3d436'), + ('\x4dc817ad119ad9345dc402fcef445f670aae71c6'), + ('\x4dcf121f185bb70b6e052954dfe89469558954ad'), + ('\x4dcf250a60fb1acc0f4fbb1f98b4630be547ad70'), + ('\x4dd5dfc17dbeaf21fe1d575df8e6cbb1b25bcd2f'), + ('\x4dd7c45100929c6a09069484bfd06d2f52231c73'), + ('\x4dd8a9dbc5c7c6a53ab254736c13fcfc4c215800'), + ('\x4dda4ed6617dedffecd1b90c114d100abd617efb'), + ('\x4ddd1c5bdcb51d4680371fe01bcabf558d922bca'), + ('\x4de7f848a04e02c58522e852b7104ccd322e1def'), + ('\x4df0352de0ef26922bef6d5ac1737a864d353926'), + ('\x4df19d92fa21605e1fe766e2f1dbcdfd390418ad'), + ('\x4df3ec66c185f8416fa40fb285a1df72e1595c2a'), + ('\x4e003f74ad4939c7d352692b9a30ad3089bed240'), + ('\x4e04b197db63cfa48fd64fd236c41bb42befdbf8'), + ('\x4e06d5e491a959ac816e5338285df061ee137e79'), + ('\x4e0985f586682aa6e629146dba4ac7f6cef9efad'), + ('\x4e18346b24d645854c144a684d1fdee40e2cf6f3'), + ('\x4e1b2d1f780cfd49ae34163b6f0a6abe96262ecc'), + ('\x4e2116476c8ed979891059ff1092b988493fe6a8'), + ('\x4e235ae1471b37398a6a431f82702480a98584b1'), + ('\x4e26236a8565fb0ab810d7758d8b4ea26906cb6d'), + ('\x4e28981a3d202835c0133690a1e7025879138e3a'), + ('\x4e2a8f1587e54cab27c6732cf9b31ba0b524a9f5'), + ('\x4e3099e94231d03326dfb5ad688a9dc986a8be29'), + ('\x4e3d41bd60e0ef4094e0ffb4940eca0c5a10ca75'), + ('\x4e4538667718ae8e04855ed386397f660113cc7a'), + ('\x4e49a6063333d166346e96e5503453bb8b48136e'), + ('\x4e51506a103d3cb0e38994bcf7be7f404d2594b3'), + ('\x4e57215684d876682eb63d943b5de390a54514fe'), + ('\x4e59fb45c329783bc7c186db314d38b488f63158'), + ('\x4e5c870af3c8008d622baac818585ef9788f7ea8'), + ('\x4e5e32a440f2222367fcf1393e2d13fdf5f5c6fc'), + ('\x4e60c0c74114a46bebca759d3c789cd05f34e02b'), + ('\x4e66b6979f9d657eb79351ccdf1eab0bf9e17ac0'), + ('\x4e6cf2b8025c396f49e9043327cefa12537607cd'), + ('\x4e70e216ec960e8979ed938d1638e4a3b59ee554'), + ('\x4e74d0eeef8c284001279025239d4b2f6aeca03f'), + ('\x4e751fee09ade309b9b497baeaf6abb9ed7b974a'), + ('\x4e7a52db47953053e9d405be046f05f102e7f2e2'), + ('\x4e7f6955b0dbc10cf950182a8ca30475b4c42efb'), + ('\x4e821a102628460aadf33c1449dfded63c5d22eb'), + ('\x4e850fdef212d5a5747213260a21bc83bb3c7ba8'), + ('\x4e882a5f69d5367aafe021261a09a4d2de42567a'), + ('\x4e8b0efc82d65583667cb7d77ff4bfceadbd10ea'), + ('\x4e8e936cf62a707bdeb7e636d269462cfbcae09f'), + ('\x4e9752a8013762affc7dfc8704d2421913548eb1'), + ('\x4e985bcc01115377676d5460125672053678fc11'), + ('\x4e9dc42200da4ade9dc759fa1709db4b7464ac91'), + ('\x4ea2d6bbe9aeb18c0452fdd37c9588c59f79281e'), + ('\x4ea5be528d6f7c6c77c527027779601085d5be67'), + ('\x4eb2bd61b3b7e8a58c25bac0aa4f553a9e7a812f'), + ('\x4eb813c859f89a041ae8fc875892212875c5c893'), + ('\x4ebe87c238809478e9f884bbdadc65e8f7b708ab'), + ('\x4ec22174af042a449381d01b88b3d3288e1ec992'), + ('\x4ec385d96bc9dfe1225bd1f66fff4aae79b0dbfd'), + ('\x4ed4c39fbf9d5c64359718cd2cc255ee77f1f69c'), + ('\x4edbf0223658efc12fdbbd39762824805031303f'), + ('\x4edc9e77dabdfd70109d6651724fed21e6349843'), + ('\x4ee1b67820727e75a95c1c07a028799e20f9f981'), + ('\x4eea6a0dd230b3f127bfbd54e5ed232e2b4bfe56'), + ('\x4ef4301b8a44eb549856039afbfba9803dac7bba'), + ('\x4ef64a4b54fae55b3ebae309eab8be1369765475'), + ('\x4ef9d15b79743ffba4d72aca3376678bd9fc8560'), + ('\x4efc65f2159633af7c35487c90732ad382340491'), + ('\x4efe2e9eaf68bee44c1a324356c2e2d445b65848'), + ('\x4f05b8ecd66d9ff2dc99909870b8a85d95f29285'), + ('\x4f074dd41040f68158835cd435b347c7bd145c6e'), + ('\x4f115c597458557cfa6349ba14826a8ef28eb2ed'), + ('\x4f15df9274e8dbd612df3f637de08fc28a3d67a4'), + ('\x4f1ede1f1121b35392da4b59b7348a2599985424'), + ('\x4f2488c4a880383e59786cd531c492f4e79d8fcb'), + ('\x4f257d22ffe429d38a8aa36e7fe9472bb02c5445'), + ('\x4f26a0069f937377745812c3ace1b1d4a733ca87'), + ('\x4f283a4ee9afa34e7b0f4e54086bf8850797a29f'), + ('\x4f29507bec715a0f6aff76532607eaeb4dee4b9c'), + ('\x4f2d100f4acd404a21aa6a8a6fbafc437d51f3eb'), + ('\x4f2ffc1eb5e25ec71676b982fca508012fe66cf7'), + ('\x4f32c89a8ace2b6c4166928f795fb79d86c62705'), + ('\x4f3695c619d78d5d26fbb5758c74bf8f9e531f33'), + ('\x4f3c59dfdc1041b67766b5664d4c701cb3e9bdea'), + ('\x4f4070d5b00dab920e541cf5afc757bd30cf1a60'), + ('\x4f478e12fad7add2809f1453a74dda2c30baa9d4'), + ('\x4f50a8e0f4ffc4efb015000f3733753cfee86285'), + ('\x4f51253fb79bc0e6730bb18438a9c029289bc165'), + ('\x4f51fbfc2c043aebef230d1e5b1cb2c780554884'), + ('\x4f57f614dd9914814ee238ef3a7cdaa81e142e0c'), + ('\x4f5fad80d9f23b5583a65e6a0744b065060da04d'), + ('\x4f6112a04d4d8467cf0e5babd607239864ca1886'), + ('\x4f6142eb906b20c8aacfe7ccc2db4627754de5e6'), + ('\x4f6427e2112df3e8c879c6bc2ae6896a58172baf'), + ('\x4f6509c67b246c3713c7586c3b7accb5aad25ff6'), + ('\x4f6964b44d72106f4289b65aa54a41aea6610680'), + ('\x4f6c79575234897d64ccfe3e6117e41663fb4abf'), + ('\x4f71e4ac8d3c50bc05af759c58ccb40de227f8a2'), + ('\x4f7232c8cbc6f489b1c4375abbc4a4cf21a62eff'), + ('\x4f730f9829a01c2f34b087eafec5533335d9b33c'), + ('\x4f732408406ac9f56353d30befeb872463efa5af'), + ('\x4f77de40f636ef240f81a5d56d28d372bae1d940'), + ('\x4f7a99aeb2a51cd99f352d4274d61452cf658d98'), + ('\x4f7c3b79581bc1b42f79529c4940ea0f64357c8a'), + ('\x4f807859f9b10eb9cabc8158e99d5a23782c11f6'), + ('\x4f8a2ce96ad2276fa4b56c5e2cb4027ac8e43cfe'), + ('\x4f91beac976fa298422c87113742652665c2d800'), + ('\x4f9479a7087072489830347e654f429c1ccf6bc5'), + ('\x4f9a19629fc4a1d872b26cfa36cd4fe250df3a17'), + ('\x4f9d9087b4c4c367b391431600fa415a5376f5be'), + ('\x4f9e17748104a772b24a8af76e26e4c7ea2d9e10'), + ('\x4fa319d986155a5b3f78a4ba29d79933ca1f6a60'), + ('\x4fa7d0c4c68afb9f209e1e72e0077037f37e90d2'), + ('\x4fabff4f1c4e88b27eb8646efab710c6ec3eaa94'), + ('\x4fadf2fc5a272bcc498707f9ad3046f9e4d8f041'), + ('\x4fae2441ae1b8de290cf89ce9b1b1fbb796dcb47'), + ('\x4fae71052ebc55b83f7369cf66804fea7fe27dfb'), + ('\x4fb598c363323765658751c3ede58389a8bdc07d'), + ('\x4fb9bc8ffec043498d14ff4bd05a92ffeef2b5f5'), + ('\x4fbdf5827b7375071a485daf293ff44e6e61bfc6'), + ('\x4fbe04afcdeaa9842023c3178666e20fb9a9d067'), + ('\x4fc2b79a5ff778bee59084c8ca68c9bed0162b7b'), + ('\x4fc4de96baa88c9d66da75488e03c529fb4e84bd'), + ('\x4fcf0ded90a4b295b47f027a0961474bd39db390'), + ('\x4fd30f33753c83aa0907b0ba449952a11a495fe6'), + ('\x4fd39bed0ad21b93ca80fd80485634dd8421daa0'), + ('\x4fd86df82cf93946c5afc109c765cccfd31a4259'), + ('\x4fd871605be82f02ab8eb15f27e35ed5d2655b4e'), + ('\x4fd885b4f5d46aaadd0e44482f38452192a6b409'), + ('\x4fd915dfb2fd899efff1c1d2133ac68186d94d23'), + ('\x4fed54efb29232d1d31241a3d055403df5a01fdc'), + ('\x4feecf350260e2339f459444023492f0a0a2b896'), + ('\x4ff0550489039b8d17be19a8724c501eb87ecd7b'), + ('\x4ff44bf19f1b65f897f4a0932aa115f1c6548453'), + ('\x4ff578399f0f8ebe8e0a0443c3862bc432572955'), + ('\x4ff730c554cb1cbf6cb171790000d92915185025'), + ('\x50024de72629086a259b658cb47d89092d1f063b'), + ('\x5004ed25561ca262e15f44c52098f791fedfb29e'), + ('\x50056064e6ebde5b2127de26bd00720d68bd0d06'), + ('\x5005934190e15150fae4f82d50d16d18dbaedfd5'), + ('\x500817704a6d6852940f69bde9ee807aa28b972b'), + ('\x5008ddfcf53c02e82d7eee2e57c38e5672ef89f6'), + ('\x500b244c9aaffb46d999b4058a4ab87459e9e8d8'), + ('\x501d330752759f740b6e2ceeaf121ad927dd7a61'), + ('\x501e289038a367a0de26589678cd670761880f9f'), + ('\x502150614eef719e50a0123f17f0472b45bf2ad4'), + ('\x5025b5cd8f5033de61c7758d153ea8e3b838d3d8'), + ('\x5028f411b511dd9c8e67d9548de54ed84bd9e08c'), + ('\x502b6516408a0a2db74c1599bac66b6ae47f1ae6'), + ('\x502bc1899d04ee4fbfb94ed8ad12f26e6ac45df2'), + ('\x502d1b4f926df302ebba69813d4f82cc2c1f9114'), + ('\x502db6995b89d7bc4d0e097638abc6006508aa18'), + ('\x50301b6b9d1470b308e36db178f78ca2133f62c8'), + ('\x5030a7f96746e426cbb92ed5b58333654233b547'), + ('\x503790576fb080bda8a36dbd5a2abfc0cbe03438'), + ('\x50394c2f2440b84561b1cf925be1e96926b2e791'), + ('\x5042ba0e3fb9812046d0d6ba247071a99d5cec10'), + ('\x505a116268418c292325a63afa066e2c03a2256a'), + ('\x505df27d715da0c5f72ebc57a199ce3d1becc06e'), + ('\x506bf23f1ba22d23f9d1280652187f0a40a14eda'), + ('\x506c5f7d1db9b4cfefa35a9ccb6fa452d431b0dc'), + ('\x507af7197e84ba675159b0d7f79f2945146936e6'), + ('\x508a68f454094361025f1f850fd052e1a0aff17a'), + ('\x508c046452583f7db41df0bf702416e68fa7fa59'), + ('\x5094daabfa2a97d979846814726dcc35f8cf19a6'), + ('\x5098aac0ec46d26cca79d204e4d06eeef53ca084'), + ('\x509dcd803bc838f0f34a658c988654fea846bcfe'), + ('\x50a09d78b890049e6a3b15eaa2afdd92a548e81c'), + ('\x50a1a1744adfd306c532ed11b64d45f847ba6300'), + ('\x50a65fa6fedd37368db624037e7c5f91c311f890'), + ('\x50b03c97b4a766a9e769ea15096e18a9bb629172'), + ('\x50b3f44b7b6f31773bb38b0f771b0cbcd378add1'), + ('\x50b7c352507cdb54182eb3dbf0ade443f0c30e8c'), + ('\x50b8adcd48ceb016bdc0a83c7209a9d09713e870'), + ('\x50bfc6cb1ffb4309232ad7bf469b21ee641314fd'), + ('\x50c30180f00a132b97e6a6797ea62e8eeedb32c6'), + ('\x50c730def9bcbc23d8098ec49e85a5066cd8b4eb'), + ('\x50e1d8d0a0150d24ef702c864bb413a902fb137e'), + ('\x50e334cee81335d2044c1319dac14d1bdbdb652a'), + ('\x50ec8aeee87ccd91fa7c76b154d7b2d937ec01b8'), + ('\x50ee9849e5721f1488b8a14073017ad256c4bd44'), + ('\x50fba49789c6fa20c0e2585d097deff0c46123fe'), + ('\x5107072d0c9887c16ab9fbea95bf336a3abb7b81'), + ('\x5115715e72e91332e161ad4bb5339cb95abc6a94'), + ('\x511637a661cb696933ced947bd16bf2f4bd2c8f5'), + ('\x5116629bfc706d416342771262daabf166f0a774'), + ('\x512b8a4d3c409eccaa3985f1956a39772bf5aa9f'), + ('\x51320f1f449550c88e4768f685252d65eb1364e8'), + ('\x5133b0b9f46fe1f77be34a86480cf0c82a00dc3d'), + ('\x5136cdb5b2ad97faff33473034d63a443285c91a'), + ('\x514dffc5b306da73208228beba83750eaad601b9'), + ('\x515245578e563a077ac925687151feaa69b5ee6c'), + ('\x5152927bd1357cb634786eb843a2b5866d202a7f'), + ('\x5152ac7a14acb60c3856104ba0bd9905fe0a37e8'), + ('\x5153e0fffae8f21504ca27f0d4caafd47b0e2a6b'), + ('\x51564c0eb20bab2b7583809a754e8e27190750b1'), + ('\x51574cd288db4bf63763d99bdcfc7201138a1ea5'), + ('\x515cd108e13db048505dbac7b9e7ca7534cf6039'), + ('\x51693691df6e75a06a5fec62cfa7467d14eb2143'), + ('\x516cd1c94878933904bc59f0e93a9168324a91e2'), + ('\x5176b06199b5bb8941858d13cf774d0742662cd1'), + ('\x5177e1f23455bb3667687e3c9325c26651e7f7fd'), + ('\x5182934a8a5ef0d737b1eba1cd64cc5c28e0a07e'), + ('\x51904ebccdd4e5726374a7653f3462999ef366e1'), + ('\x51912da6e6d4cd61568c9b3cdf3dcb1a4c104299'), + ('\x519457d64ba7ec059b103b3600d23ac73af742ec'), + ('\x51964c050479c0dba13787ddb957c466d38ffe37'), + ('\x519ac8343cb3f05f8ce8797e2bf2fe52ed111ee8'), + ('\x519d732943d76f3f2777f3854ec3ce2fc726ac65'), + ('\x519f50e695ca6b80dd7caa8b4fbb89da550fb3d7'), + ('\x51a4a3cb343f2a1f0152596f37376741a5882a9a'), + ('\x51ab80bd688ffcc2ec7a8c86d73ad09710fa814c'), + ('\x51ae7e04aeb41f7aad84cff73bccf1660f6faae2'), + ('\x51af738728e1df27515679a213fef318216512da'), + ('\x51b68c341e48303491025f6986aa388a0c128442'), + ('\x51b7b29568f9874543de4037950883627c7bd51c'), + ('\x51be796991d546734732ed3e699387ca431aebd9'), + ('\x51c05079b89a98ed83b6ccade299147717bdde1b'), + ('\x51c3d52c2c719c03f2fa30f81ec2c2df074e52be'), + ('\x51c4711eb032fb8e6db3087f48ee3d386cef56ef'), + ('\x51c49dd518c93e042f64b66d454e29552a2514ac'), + ('\x51c4c4d13cb090dfa1a3c418b67a3011d122505a'), + ('\x51c5492c5a600ddfd0eb7b353ef63b69a2f98b59'), + ('\x51c63264270620a081795647912f79a53f41b054'), + ('\x51d0ca37f12326c9cd8554bfe3dbc71826dc06f2'), + ('\x51dcafbdd2e9b8b8204fd7ce709cb44e8bfdbe5b'), + ('\x51e61a8c5e701a298c8db220eeb073a732898e71'), + ('\x51e8562ccee3475d101986daa59f99e349539b11'), + ('\x51ea8402e4dc1f124114c3ad3e8c33f05712bfd8'), + ('\x51eb9630fca65550d1796163de2b275528e375de'), + ('\x51ee2ba069349538d8894de14476fc3b9bd61a3f'), + ('\x51efff1e48d22af74bf23c90e35ed30f638443a8'), + ('\x51f1ded380376f78fdfe15d462f3896f5201c329'), + ('\x51f818923d2d8e8715ff13dd3f94991cb49be70b'), + ('\x51fb52b93238e14ad412d5a0bf8f4e65614ffb80'), + ('\x5201a83596e30799d14a79a1cf265852fec9b0c5'), + ('\x5202f8169570a2eba7054b5c700e563e4559d959'), + ('\x5207c9c470f2fbcb1c6c8e04b2810eb9d543f989'), + ('\x520bda606b7fa4b1698f520b50173cd4e35af9d8'), + ('\x520cbb440a9f55aa8a3a76e6364dc06175c61db3'), + ('\x520f35d2b8d4c3ed367c258f5658ad663b8c1621'), + ('\x5219f2d39d24d355fc5bb7820b5784d7624de6f2'), + ('\x521eb56b13e1a84d5220c08858d3db3b53c73837'), + ('\x5222b535012a11006cc30f83d47dd7cfdf070b82'), + ('\x5233cc659d7f4e557744a09c0cb42915f6e42322'), + ('\x52356011e34b7500527d85e9445b03baa4ebb1b4'), + ('\x52382c2c5a454354f3fb42e2eed60ce23c9b9d16'), + ('\x524e2c8b4e916c7e7aa6549a1fc2753f1bfe14ee'), + ('\x5257fa42841780135e86fa204d702014f7f4ca3c'), + ('\x5259fbcc614461cec2e1fde3b88d0641874011ad'), + ('\x525e67f20bc85f9f77f86bdfdd571a541ae85ab6'), + ('\x525eb035c2ec2d5f9f279ad68f085e0ae101d3d9'), + ('\x52687ff89f4f77cd21fb93ea5beb65dd66f0ebf3'), + ('\x526d908458ce736efdc130c2b47b3d35417dfbf7'), + ('\x526da613fdcfe50484dd24f2cd30899589b378dd'), + ('\x526e29169a33eff773533eb4bc6658f9bd3cbe0e'), + ('\x52763db9eecf8b96341b4d613ff8216d79788e34'), + ('\x527875b15cfb06984c04ea3ac457738a900654f6'), + ('\x527aebf53f83231f3ef88bcb295c10ab61028650'), + ('\x527c325bf24cc6cee4928af541af47b7e3b209be'), + ('\x527f6300688f8531f3c584395dd8a929cda0153a'), + ('\x528493d698c7205913d83aa879d363caa3ca6fc8'), + ('\x52864db38e4c3b0afa8a0676b0537ff80645b18a'), + ('\x52922ca3493b9c0d0d5e13c4c06fde174cbae4ca'), + ('\x52928ccf1cce45206f7fdc786bd4e8e69600a800'), + ('\x5292b6c644ed53616903ec78b03ecd72f505ef28'), + ('\x52a8aca8d536b2dfdaadd2dd56494320d17fcc03'), + ('\x52ae742b8e1e3808a9a4b00ae206049e9286c6c1'), + ('\x52b2970009ec9f8838890e2f5e7c50ec6dcbaf0f'), + ('\x52b4c195b083fc8d95408cd5643b77afb1db2f24'), + ('\x52b502be60155ecea78df24212b9646212dc3437'), + ('\x52bb752eef8478f639c48cb720bc15f7c3776bb3'), + ('\x52bccbe33a3266b08bef82c9291b5f3ad4956571'), + ('\x52bfac8bfa19a09e0c6eaac8babd29cb89476803'), + ('\x52bfb719114a37853382eeb7024ec2ced6906dd3'), + ('\x52c044eea6bdde9ca872602a350c342e8809d52d'), + ('\x52c0ffbca5163e8fd9126b192ec9583cfec9c5d0'), + ('\x52c1ea564830f16c28047ca7f16da4aa60d6a8e1'), + ('\x52c661c11c6e658b377befbb91fd7b3e9466e66a'), + ('\x52c9f68804d6ae61eab86657a2a95eb1278e996e'), + ('\x52ca3c6e98cfe8425412e809a4b000024e16999d'), + ('\x52d424ba2b7148bf8c5125992acb8a72c3d6a8a5'), + ('\x52e30ac84049404b36121b4c186a57d5440cd345'), + ('\x52e8c0c7554f588b55cd335964f22a91848a7b4a'), + ('\x52f4ed92531c0e5e30c08dadac98c06d4f7d27e6'), + ('\x52f7424556fad7e24d35361f7ef3b025dca36887'), + ('\x52f9a1c08854803484b7ccfd7cddc180e86f5ea4'), + ('\x52fa0967e29c6c72db58564ef7ff03c2c3073e20'), + ('\x52feb008c11ef41d8af16f7161b92321f2687d2b'), + ('\x5301729f8c81ae296422249fc4034e8b5a17595c'), + ('\x5301c894fa7ebd2989d7b579dd1d73d16b598016'), + ('\x5307ebf47f029e9dd154b770ccf33f1bb3655676'), + ('\x530cd66ec7e4256d74d39c1898db957eb8e71f64'), + ('\x530d3e115cdac1c299ea2af881345c36b8c583e1'), + ('\x530d91b38d22889937ddf0e181f89df4f9da3266'), + ('\x530ec7836b534c2df24c6c5ed67aaedfc7a8c9f0'), + ('\x530ede18c478190b2d528f997a8de4f332ee9f6a'), + ('\x5312aec1acd80b5ac6fd0c783e78a6fc1dc91cba'), + ('\x531813a87807659a27ef035f697122236600c831'), + ('\x53238482e921814ed3a939863dfccc1d781d8631'), + ('\x53276b6a32734c16c22c6fd63eb5e4d9666bbdd3'), + ('\x53290b01004e31a678a576eadce30433265158af'), + ('\x533dd7c6731c3c7b798d52641ef4eabe43215e1c'), + ('\x53462a97238eda1a0784681e7cda098ba4a14a05'), + ('\x5347c894bca65c0fe3c8c4f867536b7961c6017e'), + ('\x534a67f2479dd2f66182b811ae5c2ec00d4d939e'), + ('\x53522f5afea85ccbb42b50178c455939c4afcb96'), + ('\x535ccd4cd676ff826be545a8aafc018c02842244'), + ('\x53712f2a07fc1ed29fd61246aadb598a3e9cbb65'), + ('\x5372cfe35c93d8fe1d3a555dab21595560b13006'), + ('\x537424d29743b08daa75c0fbf25c32a1fa4bb7d4'), + ('\x537621f6223fdff6e356100d0ed04415f0599399'), + ('\x537d581f9aea808b7cf09648576a0d3b82f31721'), + ('\x5384c2b769b8e88bfb4c6b9eb796b96d710f82e5'), + ('\x53852d3db3d99edeb6543e9f0e7a90b3633fc9d3'), + ('\x5388b8c12a98b9ac91f3091af97ee17118aecf43'), + ('\x538b3fe69c3a157dfade49937681651d5403a0cf'), + ('\x538d7feebb8f1f64467e2b4632fc48c9e155e047'), + ('\x538da1de1fbc76aa9e79af7c022c78a38022af1f'), + ('\x53950e61539bd96c0023cc31a66b67da8c492dd7'), + ('\x539af110989304bab291fe12f792560684ad8d24'), + ('\x539eec5a638f30536d1a6dc2b84aed12f702f1af'), + ('\x539f81d5ee07ba284af5b783cdb4e87116c162e4'), + ('\x53a1072651a70455db68b8de410ce75a80074b55'), + ('\x53a448377eabeb6826f216618a938e9d929e23e1'), + ('\x53b2713de3e60c47ae3a1c85ee663d1a40d210da'), + ('\x53b54ecf07bfae9e32bc995c8eeba72f68ead37f'), + ('\x53b7f06bd8fe1d213060c1295c3624ccaa267a25'), + ('\x53b7f08a5ac80c719410288d8b5628d02f500902'), + ('\x53baf82274f41bf316a1cc7397c61495da6ba183'), + ('\x53bbd5b0f7b857e4028ad7e4ba02b00fdb77c352'), + ('\x53c105047ce4fc704014e6b80dbe5c3ef54e70e1'), + ('\x53ce447cf17ce962f56d93d7d998263223f4ea20'), + ('\x53d761c31614190b724084d740d49cb0f2b82030'), + ('\x53e4075b61ec1efb7f43e99a4b991dcfe9cabdbe'), + ('\x53e683345617dc6b8669cfa5bcbf691f4c26e577'), + ('\x53e81c3b0078e6aa55e07fe002790da0f9a0d116'), + ('\x53ec8d5da70994528da72db0d55c41d00800126f'), + ('\x53efb9874e4747d5f6c0573ed1145cd1a671383e'), + ('\x53fbc030aae0e43b70aaae54d2f82688c2d7c16c'), + ('\x53fea66079f68d45cca24fa944f8ad395b8b7bfa'), + ('\x5400bdc5849df69ef8a050c7c9311f82813ff636'), + ('\x54060cf72f528eee2218a468536b6473b026f7d6'), + ('\x540b9ac98c52ac5621a3ebfcb16f08135c486ab0'), + ('\x540cf4224ad39f43c7c7dd0d2ef2f675f3bc67aa'), + ('\x540e89034334a034562da61c3cd531a7cd8fb471'), + ('\x5413b41413ed8dad28626d536538b5b3a8ab9288'), + ('\x541553294a247007e2e25139382d4ca3fda4a57e'), + ('\x541624aba526402bab9d029e3c9a6ac1df1d72c2'), + ('\x541b6f9ada08bf44eacf5497583872913ec6684d'), + ('\x541f554750958bfc14cf6699cf1d26670ae2e0f8'), + ('\x542099e0e67f46744bb219b7086f5b920d2dde4b'), + ('\x5424cfbc760a28effa9eb2ca01ac1fc352505a78'), + ('\x543ab7662c8d016529aa1ca7eb1e14093a213541'), + ('\x543cd1d37ded6e32d92565fcf7f8a7fe03401af3'), + ('\x543cfaea4a20c27a6b3d5362b2b3f5b221047b4d'), + ('\x543dcbdcb411dfe6c503385685fe69dd21affce3'), + ('\x543f45a3b37c6b8b47c0ef6cfe03d350487cfec6'), + ('\x5440773d5968b6452be458c8b06ce946a3229eb5'), + ('\x5444e23f930945b3e52c31d664d0089978ee3205'), + ('\x544cc742fb293b606d0c77d372b6f7871cdde363'), + ('\x544d37b809198db9548cea20292dc509ecdff944'), + ('\x544fc3b5dc09eff0966c72a8d8ff63b3293e4a83'), + ('\x5455815168ab3802576a9bb855f784ee6bb23602'), + ('\x545645a280fbb6b3d5df1b4725e449ed18ab25c8'), + ('\x545941f9f67222b498064799ca224cf7e9123498'), + ('\x545fa8b42a63d35e81e6d4f520961ef67b23d18e'), + ('\x546199ed1a51f4342da7c5899580b6a12f12c3d5'), + ('\x54622316c0d5280f9cdda18273e97048c0f5e7e2'), + ('\x54650b093a2cb059eee4aafc0c6a71190ea5fb99'), + ('\x54746dde7e8d341f722776d9ac930cd8eb6c47e0'), + ('\x547964875148e35d96bb18d0f64f657d09188425'), + ('\x547e1e19356900342bb859796173de083aaee4c7'), + ('\x547f4d5dcf7194c8af805568ea7bf1d1e58053f2'), + ('\x54805d259df1279f4a23be08e5f575e07766703e'), + ('\x54873d61cc629642a0c18f5717f3ed1cae0948c7'), + ('\x5488be3b59cacf73b3bc7d1477bd10ae143b494c'), + ('\x548c31026e81828ac949d23b2b8937df5ab0376c'), + ('\x5491a9a2aefe508e87f8c7ceef7797509c28d1d4'), + ('\x54978e1f36a65ed9ef5e4af5a97875008dc738cf'), + ('\x549e1c1d5dec03138a127deadb512bb98063da28'), + ('\x549fc73c7f7b9bdae068353bc899fb7c5476d9a5'), + ('\x54a06001c8b3ad35218f67a3d1131ac9e5872d06'), + ('\x54aeff799cb993508f96ae17ab4de0c8ae50e6ef'), + ('\x54b12102a8801799ef22707b9c7364627fdfbd03'), + ('\x54b2835de182c1524ef7bc2ac54fd969390c817f'), + ('\x54b36c48a602f55a4509f6294eee3295572887d2'), + ('\x54b3cfaa2db2a9954b103197d05be0a0b141be5e'), + ('\x54b43fafc5c8c9fd5d883969dba71d9ffd98d09e'), + ('\x54b6c96628f297f47d564eb283c8bb2387bfcea4'), + ('\x54cb92e4f7fbed715ffe0a7e3b0e79ea403d348a'), + ('\x54cbdef9b66e747a1d0f8493e0df86f8bd3bbcb9'), + ('\x54cd7ee0c20afbb686c63f6be6169798244385f8'), + ('\x54ce0e56864c8c28c03f628206bdf43b8ff89489'), + ('\x54d9c9946b4959837c4a628243dc41898ddc64c8'), + ('\x54e2a08f06f723e2304a105c291dce5850a04276'), + ('\x54ea739cf0835e8a0ad6c5985ab18a9956ca1ccf'), + ('\x54eb72ccb40eb180f16dad8f0cbf9039b2cd47ea'), + ('\x54f0da080e300cbd2152be0076db9170bf232be2'), + ('\x54f28e2a40b63c96e5942188bb5cd799cd595d48'), + ('\x54f3cdcb70f7995c71e8e0db89933552aafc0066'), + ('\x54f5b9af5efcefda425364162428241a42ef2836'), + ('\x54f632cb9db2bef5bd1fbe24163faecb2787d368'), + ('\x54f7dab956d642ee37e59e9edd6de0aed7846c3b'), + ('\x54f86cb5fdc486b897f43f6eb31b35c93800f8b5'), + ('\x55043cc33d26f68016c11d3261ece8d6c9e22826'), + ('\x5505a9e98fa4c47fd06352734ffaf0365dc9bae9'), + ('\x5507473d5fbb3cdb0173152a50a112eda321e076'), + ('\x55079ed3ef4d0703d08a7ef09509b95b6beae5e5'), + ('\x55079f8565e7b435f48980eb88cb5c9e05004a23'), + ('\x5507ef14cfb9d73540390366b2eac704ee4e5d85'), + ('\x550bb8379f319f17a2ab65a280adb87d60fa21bd'), + ('\x550c5ef0a997e85ade33af6b088bf1313e097317'), + ('\x551bbecbc593299ffbbef5e5e3ff00d92863f1b6'), + ('\x551c79228e514277825c0f2bb6a746f73a47e679'), + ('\x551cdebe929e1d2cf9458330895afa012184d2a7'), + ('\x55212c6755de51058a9aa8be0f04076db5c51323'), + ('\x5526a0b01e0f73f0c8384738b84bd106e36d3282'), + ('\x5526abb16cb858f1584c1ee05e2082f3330e20a2'), + ('\x552afc3cf2e669b2db82795f38ba42946c95af78'), + ('\x5532993a31f30207f81727f6ca7c83071951ce05'), + ('\x55410a5396f4f216f60faa919fa35d48763057d6'), + ('\x5542b634cf959b47ae7c92dd63106b818b055297'), + ('\x5543aae4bd29dfa496f5ae77140c9234f3952c4a'), + ('\x5544dd94c9ee7205ece786b138cf4a35395ffa1a'), + ('\x5545656da8a6ff9add75db34866a3b45b4de6b55'), + ('\x55471827fe5509c01a3b30f2e451995ad72be690'), + ('\x554a604985bc626d7f1e60d42620d0536f89e919'), + ('\x5555e52e268a79653df723ae87bdf35aa05d8b30'), + ('\x555cb99df8353aa5fa9e28dbb666f5996f3f5f87'), + ('\x555d348e6f328034451e90a44f55d4392174eec1'), + ('\x556ef7db04472e6b6ec762b550f55d3f03e00c40'), + ('\x55746a05e851e3cb5fa5b33407f4e7692e4ab303'), + ('\x557856dfe70162941adc1a2790d299210dfbf4e5'), + ('\x557861aa5eb2a6bf04532a0cd9aabcbca6b1c91c'), + ('\x55805e4291a1922a4127e18b4e2d2341730252b9'), + ('\x558491dfa4cec5f01fa907a3c8b9c736ae6544a9'), + ('\x5588f6e674694f0746f421a65025dcd531803045'), + ('\x5589255db27c95a8d68065f143d0a6b4199295a0'), + ('\x558bb75d35fdf1e6105142bd6e7de1193f1feff7'), + ('\x558ec4b2831b7aa43b445093bde810e934756dcb'), + ('\x5593fbd5d7b2491d3845521873e92eb2b0790a34'), + ('\x55940eac0284c1763144dcb581bab67c4c7a4049'), + ('\x559d1c7c71a3c72cf6618d657c50d6dc4d34fef8'), + ('\x559d460c3d268e5f136e3d56c7b975c133acc59a'), + ('\x559d762ea7442ee708a1789347f4508f114f665d'), + ('\x55a0a4ebafe27f7ac0d85ff9850724bf38079744'), + ('\x55a1500b65b0a5b100235d29c46be4409481210e'), + ('\x55a23afa3f7dcb08fbd8e4f246b8727085dcae72'), + ('\x55a70d7d9c0c64dd1d91ff283623f4323569e8e5'), + ('\x55b72ccb46bbfc7fc0d7d409d1a13aa2098f06fb'), + ('\x55bbc2ed7538c178bba7720d9473206351c8412b'), + ('\x55d2b769064620875e99623c677461e3d8e88a76'), + ('\x55d5caf513c212b5143ef4f9fa627fd40a6b5965'), + ('\x55d724f0403474193b90f884c473ff6b73da085a'), + ('\x55d922bad6066bef6f5d36d9f52faebbdad21d23'), + ('\x55dd13907f63bc6bf89a59eb3bf0eb674f379ade'), + ('\x55de6ee83e4441a0c43dfceaea7e9722908617f6'), + ('\x55e7054bb9ba50af5c29f111e6df3c4e611404a8'), + ('\x55ea9d70eea988079ed4a40aa37f19cfb2ee6538'), + ('\x55ecbe6c37ff6635ea06af2424e5c325700fd3b6'), + ('\x55edcafe25418b56cf45a2c2737bb2e700cbaa86'), + ('\x55f58b3bae53c229f6952102e572771c20c3a8f3'), + ('\x55f6732f1d5e656ba2fd2e4fbdb42d59436541fc'), + ('\x55fd3c24c5c288112d02ff911cd10f1dde46e629'), + ('\x5601291372d50d5d1341249c30a8ab4682e2f432'), + ('\x56041283e65b57d3b3465effa085a4c464a401fc'), + ('\x560c72bafc8f59fbef0a6a1af18509f68daee1f7'), + ('\x560f9103e9af5202b85fc432f2dd4d30d30867e6'), + ('\x56112e56729e52c9f8ba6e3847d3488481e21d7d'), + ('\x561e5e170ab5155955ecc2730a5abfb185695ad7'), + ('\x561f83889e211676638422930b81fb7f85404d1e'), + ('\x56219e0ea1619777725634cd93e0b8ae574be7cc'), + ('\x562497e1c3a4646e57bea415b7359f13e339b777'), + ('\x562877e88c9436d18e1938699aad2e98e11d8d51'), + ('\x56297b7be7ab48363af73fe3e6ba7a87d911f096'), + ('\x562a7eba8deab08826660e50137752403cebbb54'), + ('\x562b9e86894305b88f2997ff8371bbe660f6870a'), + ('\x5631e2a38a683bdd1ae9db9d1fb1021672c7cb32'), + ('\x5634c2af55c7d311364f812657ced0aa0b2076c3'), + ('\x563872c7680aed4d9f8f7a4bc4f3c1506c2cbbc1'), + ('\x563b7d269e55d359c53dd19b510b2f8652b91d5e'), + ('\x5644df38bf01531fadad60f7bc28d1bb7fdfcd26'), + ('\x564d8304db52c91fa1a97673fec90e07a4304e5a'), + ('\x5650f9e72169ea4cade93db4e0962a3a886eea8e'), + ('\x565155a70050b633e26d8131486e7f51bd4945fd'), + ('\x565b81fce72859e4f3867f9b6f7d00ea75039288'), + ('\x565ca27e8c0378c91288e1fc575d0d2fceca085f'), + ('\x566070f49e04a4c4357691d33272da82c37337b4'), + ('\x5660bee1ec3cbfe177af902b4271d3e6f38c14aa'), + ('\x56622e659ef5977baf3c8d7e6ffd2fb5e24c8a60'), + ('\x56648f066b99d520a9c70e3e20b87d2a9f985964'), + ('\x5669101202343bbb6436f2030794caac4c829920'), + ('\x56707c8d9d590af218266be2615163d248a77e57'), + ('\x56714fe2aec76c02d806bc3bd2578667647afae9'), + ('\x5672afaa554a8ef98cc03275affa395cb1a3cb56'), + ('\x5673b5ba01de940c48bf9715cc913efe9ee841ce'), + ('\x567b1ae7a1b5f40d1359216206869f4d8e22170b'), + ('\x567ea77c4aae0967931f940accf1d426a04dd5b7'), + ('\x56817146a5b70ed08be5066430495b60eb2ac5cc'), + ('\x5689baac96c517dbfd3f14f311efacb7564a66a9'), + ('\x5696f2c072bb9d954eeda2e2d6d50ce8daad42b8'), + ('\x569e4a923014bae17db091a2cb84c0bba86b908e'), + ('\x56a1447a1b83933b7fc216b84cddba0d2e182069'), + ('\x56a2a57a7dee945e1a9152006e8edb1749520a12'), + ('\x56b72ae6db7aa9ef0ab153f94c914f53c32496a4'), + ('\x56b8e86a783c7c9b7f59cadef1854035471bc7d6'), + ('\x56c1016ea35b565aa64dac314c03ba92f05b5cd2'), + ('\x56c365deb389f7c4e27d137351a3d738aebb55e9'), + ('\x56cae654b13e5fbc684a2166382c768d9c547aaa'), + ('\x56cf7deed7aaf3623cb897908b76fe53d880ea55'), + ('\x56d2fce94ab0eeb44956a0a05783ce2aedbbdc34'), + ('\x56d8e401ca93fe0ad51c86310e359c2395748960'), + ('\x56d9996f419b195ca13ce890db3f4594b1d946a7'), + ('\x56daf26ae7608343c64931bbc6fa551ea131a1a2'), + ('\x56df3456caac4eed88a7950e34fbe9503141f498'), + ('\x56e7972fb915e7790d81811230bbd26215df5249'), + ('\x56e8203946a4a23833bcb5bdfccc27e5381c268d'), + ('\x56edd5cac405b6fd2683b2898b7b8a942e4458f5'), + ('\x56edfc539149d652e0a24aa9b05f6629b8fdafb1'), + ('\x56f25fb4e4c9d89086a5b75acfe2368c3482af34'), + ('\x56f9faef5a2a6272502e072b17f72b9d1d40b738'), + ('\x56fdb0e2b162a8ea0a427cc6f1966557249f27f5'), + ('\x56fed4119335fe248c5a640511aacbc3bdb43819'), + ('\x56ff3e2edd63bbf26af8ac046ee1670b3907f76d'), + ('\x5700edcd52056633dc14f06a69909da4b17fca28'), + ('\x5707f4f56e704f2148e6191cbdc0f130ad33aec8'), + ('\x57095fa1623084ddfaf105ef7e91011088228e44'), + ('\x570ec4069bde0da50542d48cdd571d267baead16'), + ('\x5719635dcb8231165f943b2a927cf8d4ae286f07'), + ('\x57209db3b12125090ced781570fb5b3165d6bfc8'), + ('\x572975aad8dad1a3e4eaf4bf039bb8bb426f6b86'), + ('\x5732a1adde744f5ab6647f8d8c580c1ffd9f12b4'), + ('\x57355c0d64307ba003dd819f3a3d984afc4a27e8'), + ('\x573ff5d8abb9056f1cb619d9faa902fcdb099a08'), + ('\x5742234e90288090a404d3258d3d4fab74b6ebed'), + ('\x57464ea5682366d77e4cd229a574d5e37eb95eaa'), + ('\x5752aa139731f1ca794e9dde33acc8ece4128dfd'), + ('\x575852d72fa0acf26b4a3d02d62ad1705af885e8'), + ('\x57657dd8bf11bbeb932bf128c8a84a1576184716'), + ('\x576e6b07df0f97cb66a2bdefcab0e9421456f569'), + ('\x57791c11000219022aa4c06e445c60e485e6e9f4'), + ('\x577b08366a1c7b23e11a98ba4c91c37f58c40b53'), + ('\x5780e3f7d4dfe95b034111170bda04b1e72884c5'), + ('\x5781727eb2557b9c4d6a64517221a5f634ee97cc'), + ('\x5781a74bea92eb6e3ff47f2ad3b5766c2c9043a6'), + ('\x578542cfd9321c98e5e913e5056ed244e1b256d6'), + ('\x578836a77fc1ab366b5e928a773fea60e9e6ae1c'), + ('\x57898ac35042c93151f9fdb3a9e3ff9d5d837b83'), + ('\x578fb234d2c1350a302d7a139065337da0087aa4'), + ('\x57991076e870cb14492cc2344af76d615681d72e'), + ('\x579965d16129098190cf10a542ee94d24e56c85f'), + ('\x579a4ce6454f9df98841b02093fe39fc3115cc25'), + ('\x579d9990c3365d96d7691177d983b17c9234362c'), + ('\x57a126a076303fc02604d064f0458e5bbb5279e0'), + ('\x57a4c287f62e943877393fbc7bbfb40395684704'), + ('\x57aa66473f016cc93ff43c6f7df528cb79efecdb'), + ('\x57b4433bbf00881993a8477e6294e9afe36258b9'), + ('\x57b44bc541de8938b23497442e5a018f8f1a0fe7'), + ('\x57b6c3c574878ea5fe679098640d0d8470c2c764'), + ('\x57be3a7c7a363572931e3dbecdabd80b5fa8331c'), + ('\x57c2f9574ba30c0c07c601ccaf607aac6a708b50'), + ('\x57c67d6b90aae4eb7fe78c73751fe879da831a9b'), + ('\x57c6a3efa752df25dfa04293bb61536219b11bd5'), + ('\x57c846ed57dadd024c47de4a52cd510e65c6ff0a'), + ('\x57d0a26503dc421d49c9f3f5f2ef34da9ba336ad'), + ('\x57d643fd230e51ead01b43ad9dd2f39ca7f4d902'), + ('\x57de3b440abd855f3ba15f6e4fe712fede06ad01'), + ('\x57e5679cfe5605414e7d5bc9e12c824be96ad6c2'), + ('\x57e67ead66c7a7fdd83db43371896caa2bfd7220'), + ('\x57eba0abad8a2514a39b7abe090815c40d69d1e0'), + ('\x57f8fba6db7f6538237855d984e86613e2339567'), + ('\x57fc929c331f016dab3f218877372ee348bc9c29'), + ('\x58009a583974e91d0d2183a1de8a50a5bb817626'), + ('\x58016c71f006ebed224e571985a55e53ef692c8f'), + ('\x58032a7aa5be75c442d04a96d7de0d7dc4e6ea4c'), + ('\x58055f398dafb4abe750e68b2aeeb688dc16e2ba'), + ('\x58061fa97f739c934d6034a175ab8261c39cd9ee'), + ('\x5808abf47cadb2277b8c6cee454be0ebb14c07f9'), + ('\x5812c6ae18e9d285ca6755b2e519c3ff7709af1f'), + ('\x5816b475d9982204ff7824032709cc1fab2beede'), + ('\x581cd056f467857f07c195a4e96ff5ea342caaaf'), + ('\x581e12381be6c30d54a862212cbfc64f40403e10'), + ('\x581e22a1331d5784a292b8daa1d3ed8987f973cf'), + ('\x582805f1c285ccb1a28b09c49780ff078dcd91d8'), + ('\x5830054db36b81a131a07b8a0c4b9700defc25b1'), + ('\x58331fbf3d74c6929bef6cb17eecf11e264ee64f'), + ('\x58400901bc46fac05560910011bd4f6d55546081'), + ('\x5842b522c7d62932e7673a94bced3073ea057b81'), + ('\x5844fc99af64a0f2800e60356cefd6d4857b67ba'), + ('\x584cadd3efb5f788de41bc93da587233e6677130'), + ('\x584d61a34a91359a150d189db8a0102dad449543'), + ('\x584f1884e02d1ef61e9f43383e54333506e12618'), + ('\x5853942a28470e14107a3ec54cf12b09c52925e4'), + ('\x5854bd9734f617444057f59749009b77086a445e'), + ('\x58565e30053163cefc1eacebb688f4b37a439a48'), + ('\x585a8ac72ef106e473c4aba16599b11ee440b54c'), + ('\x5863ff398888428e62683877c8ff13c3286d3837'), + ('\x58660c49b183148648dceefbf428f8f5f5a204c8'), + ('\x58676dba4058e443060f32fdda2a88852a1882d3'), + ('\x586866b9381fdbf4e1ae38e5a538349825f256bc'), + ('\x586bae80e6d9667f7bd209e6c653f49121f6bc58'), + ('\x586d18314f01f024d1f8380c73a33bd800c08aa6'), + ('\x586f763f68d8a74f4f478ebbf509158e3c9ce5db'), + ('\x58761422a189d0066578a59feec68696774a8586'), + ('\x5879b4cd63880940bab3ef7a5a9974d7db4a8791'), + ('\x5882e12b06e3d1824037bdb0fcc7509888fa48be'), + ('\x58860deeb50c54fa41847876c9daeac5cb4498e5'), + ('\x5889cf95c516da28d2fc85a2a196adbed7b530d8'), + ('\x588a1b9e45e361a70d04a52660e41d10834076ac'), + ('\x588ffbb2198a1e6a5ab6f02b41f579987cd65036'), + ('\x589168370c1d387ea337f64036fb35bd5fb258cb'), + ('\x5893f91dfbb9985c157a92c37bc725d0001ce769'), + ('\x5895bbac9cd94447c1ff4f37f9c7ee30f81778e7'), + ('\x58974042d40cc238fc1363514288c2224962b1fb'), + ('\x58978444cf2fd509984b93fe43f93d387ba46f16'), + ('\x589e58d8a198563f354956148a9cd8d703c3c81b'), + ('\x58a391302f1564324a4626083939b4d09e73ccd9'), + ('\x58a4ecf1ddc84f08e476a372c2a25d5edbf2de29'), + ('\x58a68c61842349a28282d838da57881488946f13'), + ('\x58af5b39bf2ff3989cd93e4d2ba2e13faf36c22d'), + ('\x58af6c2133c930dfc40e9e98463e61a4b7be3a4c'), + ('\x58bd8f9b5f27d8f3b848ae86eaf42149a3d2dd93'), + ('\x58c439dc366ff6808d03393ead472d813fefc0ed'), + ('\x58c5ab3fd34ab6c9e8840b56f597460af0083be7'), + ('\x58cfe90c4c0d7eb8789f9812f2304aa65eaf54dc'), + ('\x58d247b479562bc96dbe4e4fbb982e3ff9195e86'), + ('\x58d281319eb9683daf2896519f0fd977cbdb40c0'), + ('\x58d2bb2eb81ed11806ecb3d5c85f644c8727fa10'), + ('\x58d45397121d6784a089d8e2d7082761149d9884'), + ('\x58d56eef8843aaedfb5789c726c2cb741e88c42b'), + ('\x58e9e705d6b68fcda1e0e85fff3f61b051ce6810'), + ('\x58eff79cef38741205d742e378aff53ac2db3808'), + ('\x58f28a8b82891ccba552343e2e1e6258fac79b93'), + ('\x58fb3e93b5ae41abaa6fe8de87cc4f0656fbfe6b'), + ('\x58fc85eacbab2add11bf4595e1428878dbe4da6b'), + ('\x58fe4917aaea72a180b88ece2d90050fe19fffac'), + ('\x59007558e83ac69284ea1a798e7b70e647bff275'), + ('\x5900c70589678f3ec45d454c50883e231a09c6ef'), + ('\x59012ef415fc47991c189cf250151e837ce6bd52'), + ('\x590abdee32432c0f3a4ba85c219be537441af960'), + ('\x590f24b1815ae07979882b9b601a319ef9d3545f'), + ('\x5911eebc5779895d1b062f382cd2bc53e6e960e1'), + ('\x5913a6acdb22476c6ef569b675e2adf1db3c4c49'), + ('\x5929cf4b40be453feaa63cdb1eddbee0bf0e9124'), + ('\x592c1b94bbe8b07e94972192b46a210ba85251f0'), + ('\x5934bf7251f37ea81ee5321fae0ad9db0324e032'), + ('\x5941914bead36068e25a092a5dbeb793e06585da'), + ('\x59425f921494a30a249504ebd809a73039fe0f8e'), + ('\x5943d44784c178976ec5e0b8a1f739acd9839d0f'), + ('\x594a342957838a1fb90b971aee5bfa57a370fb9c'), + ('\x594e5d748b691bc908e7f317d4a13b5085381b3f'), + ('\x594efa58c1210c9bc31c586a02ceb34963cfc065'), + ('\x594fd8680e3df632803edaa6f17dc81e77b6c2a1'), + ('\x5957c9c55b61060b8e916768eef37e0e7fddabfc'), + ('\x59607f268ccd561db71a0912f89287f8a575784f'), + ('\x5967ae862e4f191a85fc09ef95611f62b7314800'), + ('\x596c46395fdee1588500965326afa84c78895160'), + ('\x59749d9c20f95386329f9a69fab5cd5126a88202'), + ('\x59757830eb8bd90b9445d0889761bd2cd6bb0e9b'), + ('\x59820d825b67e6a14c310dbff77e044cf131af64'), + ('\x5984c57a19681e0727a01b71d7380c0ebd685deb'), + ('\x598521e6ac047d9f54c2acdc750a64c24820b2aa'), + ('\x598b06918892e69e9fb480a1a85a0298a5c82717'), + ('\x59951bda495515a387df54fd3587fbf2148700d3'), + ('\x5996a2fa1423e850cce516352fa068a7521d3ee8'), + ('\x59ab003b28dd1ebc71e40ed38a6ce96a5835cd78'), + ('\x59ab4dd6d6c1642253d6b7e9f56fbfd360edd4d1'), + ('\x59adddb0391e2b5b8e13a8f2ef75cab57b8ef924'), + ('\x59adec25964be2a0411a97d6adb1875dc3d4f6cb'), + ('\x59b63278d2406a387d4fc751fb574d3e526f8eeb'), + ('\x59c051262a062b6872eebbf608643141486abe41'), + ('\x59c233a3155c49b75c38f9e2e98c66834296651f'), + ('\x59c9c19e61afd0c561cc9a4d32328036a6d9dcc6'), + ('\x59d489d1771b3efb177dd9fee51e1ea35be26367'), + ('\x59d60d712c58ff016ad4dc83b7836a3a1cd5343e'), + ('\x59df7b1e42a236557e9584409118c228ac7e6bfb'), + ('\x59e182ac162bb89ac7bf1f466c821d49ec8608cb'), + ('\x59f1b1029dbf510c4df2fde495cb669967613fcf'), + ('\x59fa5d56a0e751e8f59432d4e1fc471d79c9e336'), + ('\x59fc99e7e4b0c14b783b1cfa3e790bacadd59166'), + ('\x5a0534bfc2e63ec412aeafb923543730e7eb4540'), + ('\x5a0f3dd0e73b380f24300e4e13ac10c9341b1df3'), + ('\x5a141d2bcfe361dfe0c6954814e26fe66f705be5'), + ('\x5a1e9d65767ca0526ddf96e5de19c0414df6649c'), + ('\x5a1fd4e4159371a8fc2e79e229bc5052f0b3f5d8'), + ('\x5a26140c747c0584708884084849c1e3bc88867c'), + ('\x5a2a8f567a478024efe0a0f3cb7276006a7f4983'), + ('\x5a2df9b1cb9ed795ce4f627fc2c045d2e2b0b022'), + ('\x5a3004c0e8fa1e4ff070172d330e4f02470b4d16'), + ('\x5a32c3e2b3dee406b50ba2afd6b5ef6c15a41db9'), + ('\x5a3be9cb1df06fcba97cec839d5bf0af801dd189'), + ('\x5a40603017467d79522c55313b0c8497502f00a0'), + ('\x5a4369788d1f611a73207061a492a67f356e7843'), + ('\x5a4c8cd9d49e99813b97e7ea70f90c47d738372b'), + ('\x5a501b7d3344e6c4b96fc56a2b0259c0da037d6f'), + ('\x5a52d0521c859d1da8d6c3d14bcfec873e5c47cb'), + ('\x5a5562029c8c89cb1862e0c6d39f4d2d16b318e2'), + ('\x5a563da93bba41a372e0d6f928cb19c8c7a564f0'), + ('\x5a5bb83e05c09c4ad75fa69870aeb1993f0a0317'), + ('\x5a5d4309525ba27d915be22d739599cdbdc3d7b1'), + ('\x5a5f1061ed66e6456c28f284b885c3e9f5c86a06'), + ('\x5a619328305771eb22277ffab2b9ec832dc84430'), + ('\x5a63cfc2a288c158448d97ead9cc3b4af1e1a247'), + ('\x5a66312cb2edf92bf6701e9fc490843e6731294a'), + ('\x5a66c529dbff38b59c3b7be7f373c9803218c570'), + ('\x5a6a0baf5fc36564b8a14f5b64122327824e8031'), + ('\x5a6b38be7c5bb694ef60d68a4bcb2bf6af9d84e7'), + ('\x5a732f106f2b2a2dac6c911b0a87e2144409cee3'), + ('\x5a7395b9edd64550cc214418ea9a4732818a056c'), + ('\x5a76464ce82a37e64c6b47735da3b0b400ce45dc'), + ('\x5a76ca26ce0d298325db5e48c9c830436b03f749'), + ('\x5a78351a4aa351885d955fa39e0ce409f4ceb148'), + ('\x5a7f242843468e27650fec7a1076df6dc15604b1'), + ('\x5a9144ee28c8215a20a8713568e4057c9e6f5ea1'), + ('\x5a9c6031021e871a28628dd4e0bb995ab7ad061d'), + ('\x5aa04d594113ce21419f0949528527e5726a7d19'), + ('\x5aa1614da7b1ec78dad843c25a242a0fba466fcf'), + ('\x5aa6feac41d4fd53364d18a32b91c4c67a522c0b'), + ('\x5ab624f333e3206788822ab8705b2b730c51ddb5'), + ('\x5ab69d21ee2c585af3b09e7e33333abadb707436'), + ('\x5ab92dd3e79d60f4a958aa126fbbb5c06a49d745'), + ('\x5abe7441430ecbaec355294b01a8dc79229712e2'), + ('\x5ac240c79822e516212715334052620477ea1769'), + ('\x5acbf777117065523a5110de89dbacd6b77de24d'), + ('\x5ad44cfa774d8d078c96ede3da85594619224c68'), + ('\x5adf2c2ff8025323bfcccebff5aa20954fd8618f'), + ('\x5af1af144a23de3b3bf4f8d38b7ea81053d030dd'), + ('\x5af22686d57eb3b06d4d775387c302663bbf17ac'), + ('\x5af3d2eb5b30212e2add466eef493522cf836f53'), + ('\x5b0920ec48d6ff197aef5976cdc7fc23e6664df0'), + ('\x5b0a68217cbcdf0340b719a58a30e4cd81991c4f'), + ('\x5b17f8b6ca16cd18b948e4c14a9fb0f5ec5661d4'), + ('\x5b1d1142621fb507f22e0ffa509d81743ba61830'), + ('\x5b1db0e8ebb4c03e2ff4a3bf6ed68a5a0be540f6'), + ('\x5b20fa47b60afe2eaa54f157848747703d75f03b'), + ('\x5b29804f23760a5f1171c0a6920d95dc33ad55f5'), + ('\x5b2c6971c67799e451bb5e94e632fb7721040617'), + ('\x5b2dff9f25353b62ffade8be6aed59a3b6bf7131'), + ('\x5b3c51088393e05706e2fc37965399013f63fd95'), + ('\x5b3dda6e4dc7277dbefda8447743fde861a5ba8e'), + ('\x5b3ff1d179fc0dd0aea44388e884295bb59f18ff'), + ('\x5b444ef08447847ece5fd6cb640a3fbfc7c55887'), + ('\x5b45eeaebbf5f45e15e2ac6f41b213e4da073008'), + ('\x5b46a2e9a227bf2377385e47e2e4e2cf6df0cee9'), + ('\x5b47aa03c383bea853405373c14270b5264358b5'), + ('\x5b4a2801748d2307d03159b4c52d788984b9c8a7'), + ('\x5b4b5afe6ee4b560b65b2f2040ad38f6c094b347'), + ('\x5b4c718f16ed4241dd2e118a7cb7c3be2adb547a'), + ('\x5b4e70fa22a74c67dace2ad51f9d79cb84003eca'), + ('\x5b5118b823a929b753cfe9e8a8bf808d6c4ed7fc'), + ('\x5b54acbcf550480de6a1bbfa22cd0effed4ff85e'), + ('\x5b550f9c8e7c54a9754565a2c54257c4f4750398'), + ('\x5b55ec98ddd25f9cd38a35c1c5df2aefce4fd63f'), + ('\x5b57279bdf3fb24d81a19f855ca70499ebc4eb5c'), + ('\x5b5901370ba999692c2c9580505cb7bf1e14995f'), + ('\x5b598050f70d8cd6c6a5b50ff55294e1c09ad7ad'), + ('\x5b59e5b8ee7bbbcad8a60c68ab7a98c40d0fb611'), + ('\x5b5e4bb0dfa905a34a90e66966fe236d578bc8e4'), + ('\x5b61bf94090664b31e8d52a7f08e8fc0876afdd7'), + ('\x5b68e0f18b015dba4224f7068acdf61200d19397'), + ('\x5b6afc62112fc7ca00a8aaab914b3ed5f8cb17ce'), + ('\x5b6db8397e2ebe9eec70ddecb93145ea4928a884'), + ('\x5b7ff6612b51257b0bc429bb6a414691ac1a7977'), + ('\x5b889ef3cf71c83a4c027c4e4dc3d1a106b27809'), + ('\x5b9303709a952a9c5a44e47d49922e44c8e0f961'), + ('\x5b9bf54e1b1a6fb44e0b4a4a07e8a61c0bb1dc7e'), + ('\x5b9ccd29448be6336f8928526cf545f2fb00e008'), + ('\x5ba03ff2781c46598617df7695d95dfa66c9e8e4'), + ('\x5baf7ffc6b75a6813b4887474595411bebc94900'), + ('\x5bb1f502f82a52be77342ce2a6e04a94983794f8'), + ('\x5bb4901a06efc2a5815effd86a87960a9fe1f71d'), + ('\x5bb5f4b91a81c2993bc22550463eef8b75d7b128'), + ('\x5bb6040f3b328bfffa4738f7790b658bc922a6db'), + ('\x5bb705a4a4e80e3295f4874cc3e1caab08b6e602'), + ('\x5bbd52ec3fb227e9a49837d8ba05d3904ad19cdf'), + ('\x5bc489f160d1e7716e0d3a3db7ae542a3e9308ca'), + ('\x5bc76567ec7be3e3f6e8e4ac844d6207b1d5f1d6'), + ('\x5bc9f66410a90382e6559b850f934d6263b89fd9'), + ('\x5bca09485c70707ec728cab9c999074d1e7ddab5'), + ('\x5bd1ea6e46becfdc86e243c1df06184b8828ca04'), + ('\x5bd57d4f89a31a1f9e9170d3ae41f401ebe8c195'), + ('\x5bd68201e3037c625f3ab608b2b8ec803659d2dc'), + ('\x5bd7eabef2c286246cf502bace860153821fb0d7'), + ('\x5bdcbb3013e533916f23e736a05f8e7db733e3c7'), + ('\x5bdf252d9cfde17fd780d972aa9a9cb3d497296a'), + ('\x5be5cabc17c2821af8346f67832d5c720afe5450'), + ('\x5bec1b4732bc453a7de121456207f63288c1b529'), + ('\x5bf114cb7ab60cba6df37a7e5583d97baa86cd63'), + ('\x5bf6c819ad608a272ba55c179068b4561020797d'), + ('\x5c0c4ce3c7d174c31b24537ee08fa5030bf1efe7'), + ('\x5c29603c435ac29f8c89828bfcf2e0b5fe35c608'), + ('\x5c2e5d761d4fec2f140bb53b9c2c4ad1c49b1a45'), + ('\x5c3023bee0a791a204c9c3a90bfe8a7dfca8eb9b'), + ('\x5c304d1a4a7b439f767990bf1360d3283e45d0ee'), + ('\x5c36936a522c88b6c525a34e583541d7ec56a66d'), + ('\x5c38838d93a2ca20305d097d683dd36c8c553f76'), + ('\x5c39d8c096b5bff0c887b77f394059e8b2cbc535'), + ('\x5c3c987f6bc35179e1a9c92eb9783458be7c4bea'), + ('\x5c3e09b39dc2cdfe601b0e86c5ba5bb55fadcd33'), + ('\x5c3ebeb78203cc33b2b55643cbee82d460bc0efe'), + ('\x5c4bfbe8af3ca95cb9b21ff4f5fc734c83e4fde0'), + ('\x5c4c9034a156cd53471e532cdd71dea5259d067a'), + ('\x5c4fe63c3efee2c8e83856dc940685e77642c3fb'), + ('\x5c575873b6813024db7ebd13279f6a8bff630fbb'), + ('\x5c5a295555180cb354eada0af50664ca973510b0'), + ('\x5c653ccba54742944a0469749cddcb1233d11e9b'), + ('\x5c6692982d68c2caa6271142cb6d06fb886d836f'), + ('\x5c6ed306e7a30df053a2d4e9ddb0e4d259dd2047'), + ('\x5c710a8e0910fa3dffef4c7f7c47aea08fc074d7'), + ('\x5c71592e9017effe3be45e6e90cc41e82efeb913'), + ('\x5c735025a70e80d4b8ade2c3cdafcfd65dd22ff1'), + ('\x5c79f4f7e0d2f7a2cae5cc7e56919e4ecee64ce2'), + ('\x5c7a33a6cc0b274aa63429d4b55f4a3fa57fbd31'), + ('\x5c7f8dfc162c6e0aeac73591ef1b97d4305c6c67'), + ('\x5c87d30976f01a6430694bb3ad6d80b2a08a1798'), + ('\x5c8882bdbf5e6e75d7c87770422e581bf724aee9'), + ('\x5c8d9a7668deff8ccfd91c24eeef5321f134f318'), + ('\x5c91f0c61a0db6f4ed78d2d751363293450076df'), + ('\x5c9647e8ced2e768ca9a526ebb0ec1f3e05028fd'), + ('\x5c98a253a2ac0af7d186c6fa0282bd808aed5502'), + ('\x5cab1a1a32f2552645fc124fc7982b22b02a1f1a'), + ('\x5cafe35522405455af3c4f7a6c1dc2cd214306ff'), + ('\x5cb50013d29d0df50c7c06b33c41a168e4d643f5'), + ('\x5cb6f730198c50ac601973bc569f6eac9f85f634'), + ('\x5cbc74ace874de09712b845a6bccd53c8be62380'), + ('\x5cc2ba8f17007c1b28248d5fa6163826c9cdb70f'), + ('\x5ccc3d1ea899613b6d0970b7578210d450525129'), + ('\x5ccc878abfabca6261548a754505f7aed91a40a3'), + ('\x5ccdbc0e9890bf87da983262422753449b875e73'), + ('\x5cd085e7cc1ca6ad6b1f1a12b82d300702614fe5'), + ('\x5cd3c4cf01c9df1576ca04b8519d6205252d1c9b'), + ('\x5cd68f0655fa13611b278cc7b1215f6b693381bc'), + ('\x5cd9915382a3e9c469f593f7c3f88918f7d41789'), + ('\x5cdb41e08329d60793082372f74fd0aa914677ab'), + ('\x5cdfd50052eaaeb0868f9ff81528e51eb5c181b4'), + ('\x5ce1f2144ee3a8a65b53f31d4b1402a3f758b959'), + ('\x5ce2a009e1fc59f533e8d0af5f61bcd4709a34c8'), + ('\x5ce6761ca815f9993828dbe8930c084ce9801c18'), + ('\x5ceac3b5f0eeaad70e699521ebf30e6ac421903c'), + ('\x5cef9de5d1c7e17d571ece818fafc8bc539b896f'), + ('\x5cf32eed62f38af75e7e0b8368a5c2da83b7a0a6'), + ('\x5cfa6e0f134d088c907c2090560cf06799f41137'), + ('\x5cfab117988c37d5ecb03e35b04af89f741bac46'), + ('\x5cfb31b2f2248b19965fc48e55c33eee396cb7b8'), + ('\x5cfc0e6e51d1fd40558eabcedd2b26b644f14551'), + ('\x5cfce9d7543d1b772d3a0b2884ad2da07407c5a5'), + ('\x5d06e6e6c44c54e16fa2e42159ea6ac0f78701f6'), + ('\x5d06feaa4f1e4b641531f26e9169e94f96dc854d'), + ('\x5d07e9065ae17d0e31afe9ae3792a18f45d7c602'), + ('\x5d0c61b58305c947f80cbd6cdf983cd5ec9530c5'), + ('\x5d12293c0e466d34f0db0a2edb1b47a68da02c85'), + ('\x5d391f873a52068c63b1672580874517826ee80e'), + ('\x5d39b03c57f33c9cff814cbd1597259a0ffe4042'), + ('\x5d417e82b250edb5322674fa21b5723960bf5e7e'), + ('\x5d45cdbd48ea7554f5b263afb7c978b878fb66b1'), + ('\x5d4645c6e5b47782443916601decce6bb7c537f0'), + ('\x5d4f995fdf94848a7fedefc5684f43845061af41'), + ('\x5d57bb811a174953f1616200b1bcd8b68451a96a'), + ('\x5d62b5ca4ffa54dc8a2cbb7e3f34ab77d4506187'), + ('\x5d64b4af28199a806b05a303779ee44c2f6d26bd'), + ('\x5d6cc55ad759b2fd27b987b1d64ad2c17224761d'), + ('\x5d6e0df63e96865ad8423c3235a01a7d994a82d1'), + ('\x5d739efe9f73f7dcd8b3ba7030c78d5c5783de23'), + ('\x5d73f9ce71e0d088af333c6e504b9eff746b82f8'), + ('\x5d79685abc6dee8d6b272b83aab16b145d8e6071'), + ('\x5d7e83915d5de9fcef661ef0f1f649dc754347c1'), + ('\x5d82820e1dac06f5626c24b604dd67aa468b020c'), + ('\x5d82d41fc4691f52291a523a9346cccc0d070aad'), + ('\x5d838858735f8857a29d6e2195a60cc6a0ef347c'), + ('\x5d8388d789a7dec4f93ea7840e920c0c09e1bab6'), + ('\x5d8ddd9837db344d6e852de7011710124b45edc5'), + ('\x5d8ebe8fd5bdf4ccc491c5dca92e1b58f1a235b5'), + ('\x5d90d5ec28657d6f0b41aab37b7d890866a65844'), + ('\x5d91c553df0819a697fe78d778d03beaf96d1749'), + ('\x5d944ab84ca64d3eb1a09e91a7e00ef81c43d425'), + ('\x5d9a649dde79f3fe03dbf3ca23d3de71c3669300'), + ('\x5d9a82c557c49284e09116061b8cd768c9e7e157'), + ('\x5d9fda0f38a0c0cba357a61cd9ca326c78f90dca'), + ('\x5da0c00d86b3ac74ec420ec6ecbfb32fd504d4f9'), + ('\x5db1767f0f162ea97ded64300d96e07483ef9913'), + ('\x5db34946d247f663c85bb5a13dbf502b5875424c'), + ('\x5db710300bbf9556159dfd723139d137fb2b18c0'), + ('\x5dba6d5eaabe9b9e955b845c8248f4f84b5f00d1'), + ('\x5dbcce28c2ab5afe55f64835b5738fb5177f6233'), + ('\x5dc1b670664205422a9dbf67e26516d75ea3c426'), + ('\x5dc91cf86dabf6fc9f4f3c8e4f7eba83d65ab9bc'), + ('\x5dd41722b468b47cc881a335798b7eec0f44e259'), + ('\x5dd5a0e3df3942c9dfcce41f86b993f2fd0dd68a'), + ('\x5ddb32ebad51ed66024a9270012686b23005c6e8'), + ('\x5dde0d6e5a2a41ef98937dd6ab965b1d7725c84d'), + ('\x5dde40573c277abf7d9f2c531ea477e8d2d2ec25'), + ('\x5de3389e44e550fd1b4e8196209d777d586db4d4'), + ('\x5de95f744a74bd2f047161540db59f4f735883f9'), + ('\x5decf12973e90db8a9014fe9d42a67d8a03f9489'), + ('\x5df5445bc0f6a482067b1e089727be72f17984b2'), + ('\x5df9cc25fe0c21c8fa6db18397a03c7f13e5f034'), + ('\x5dfc77a13706c3bbc51d18dbd358f5f49be32387'), + ('\x5dfdfab28dc56570671ed995f5bd862cc4986996'), + ('\x5e06bd76077f859774ac56c2c246ca2bccc85334'), + ('\x5e0a8c65fae797f4a8cb17ccc00a4dfb0dd882cd'), + ('\x5e0ead2b06c1a0c864446bec750491481e5fd641'), + ('\x5e13151261a72e9411af354f1f20ccd3b09342f3'), + ('\x5e134ec8ffaa69b57128987a16b2a66ec76b9d85'), + ('\x5e1ea1da9485e7c5547a531406b1cdeb007e8a9b'), + ('\x5e1faf7001ed6fe9689b9b8188e53f3e10ea5911'), + ('\x5e21a72f3a6b15e9ab42c578a2db6384905f23b1'), + ('\x5e24039a1631de597e81da173b093bfc90086092'), + ('\x5e264be55ffd71356775d9b149a033266c475d7b'), + ('\x5e29b4f3a66317c0f09303fc318e31bbdf3dedfb'), + ('\x5e2d1839b4c77e934b604b35afeeb47d4a259fba'), + ('\x5e2d88743709ad4017ab9ade68e2420d119019a0'), + ('\x5e318ebc2d9292f555f86790a55c81afdef08fd2'), + ('\x5e32e3f17a2a4be386a50d86c63c2a136602cdd6'), + ('\x5e364521ba6b97350b46f452b2eceff7bd09a8b9'), + ('\x5e45ad9a6c943fc034f8f177d07c552e5fceda77'), + ('\x5e46e233e56eee8f5b998a7517c3f909fd4b074a'), + ('\x5e46fd52c370fe39ef6fe30bce89a35e4a49c40e'), + ('\x5e4ffad3003a6b7fca9a64e8582bad99869d76af'), + ('\x5e52a0d63828e95b0196700991f374ff057d6fc7'), + ('\x5e53eb7a66097a2fe6b8252e6215f0160fa9cd57'), + ('\x5e58d64a0301727645c85e342ce674db04f4cd47'), + ('\x5e5bc726d31a3768202f8ff82db7c6546f418dc2'), + ('\x5e603f04f13199f3ce8b8a12c9a6d9327ee31368'), + ('\x5e78e1239a51bef1e560047ce4e47140a4f08947'), + ('\x5e7bf7635316e50f0480dd274964db51ac1f0bf0'), + ('\x5e80e4c32d4938441a9402b60dd4d0856a11504f'), + ('\x5e82d878cdadefbd55603762e99a9e694d755c5b'), + ('\x5e866375e6d46984cca27f4b62d0e7082388763b'), + ('\x5e874985bdd3f12dbca18f532da6f556be699173'), + ('\x5e88ddeb2404a60ba7785629ab00d2217043bfc2'), + ('\x5e8b1ca78e76ad969abdcbdbe4542fc33afab7e1'), + ('\x5e97e922eb2afdd49a0853243bb34db4d4031278'), + ('\x5e9aee9bb1f8967396aaa55aae047e0d2a19668b'), + ('\x5ea1780895df07622a713b6b19bd8235da8fe11a'), + ('\x5ea1903277d2018fd657368714b5c2a64700b43b'), + ('\x5ea2b940f5281f89d481b7d070933d9be15a0839'), + ('\x5ead07518a0f2487d0556b49dbc0d58e8c0e66d1'), + ('\x5eb44c97180bba96533bd472ad32760a296ed625'), + ('\x5ebafaeb74daf56a3d6658af058d710cf0f5dcff'), + ('\x5ebe2a2994aa32ae9b00233f99e9192162babfd9'), + ('\x5ecb3df71bb60393dbcb8900d6bad4d400f20d1c'), + ('\x5ecd75d65b2f1c361f26fc515c840cc136640ed6'), + ('\x5ecdca913ff92055a060117198de1ae25a8ea5a9'), + ('\x5ed53b22b9d3041c3a0228ca79b79660d39b2a3f'), + ('\x5eecdf6e126d711363e54a64c394eea32247bdb8'), + ('\x5eed0d2ab159fa519d54fe43910ab0221428a31d'), + ('\x5ef2a23d33913bad16921eea3d16b7a3e447b03f'), + ('\x5ef32a7ab4839da36864f2010580e9bc436bb8e1'), + ('\x5ef809a26e1e0c52d7dc77884fa0a4576bc9254c'), + ('\x5ef976ae8eb5c6131cfd9d3dd375b890c1cdd181'), + ('\x5efa27a8c84c47446d8f74c7a8a9b757d0601383'), + ('\x5efc0956f2e23f8f3665b746678b47e04e470c01'), + ('\x5f077e4bf1d7e74f27077e17028bf306b2b5887d'), + ('\x5f0cfd56726b9b7a56f54c1c87f1fd50c409a657'), + ('\x5f1b31e0f07b7dc5840447bd409401ae5b310cdf'), + ('\x5f1f74f7d67fd19247c1cc4f326e4287f32aaa88'), + ('\x5f240307d151b9c154c87fbae0cabb46ee2a3754'), + ('\x5f243b435e7255a39702140ff3a8ece570b7c8bb'), + ('\x5f2fba86ebbe98298b0f388999ebb572c3f9ade7'), + ('\x5f41cc5221b6c4cc12349b835d63af19fbc47194'), + ('\x5f455db2727a8ea0f6fa2c44f66bf5e73679b36f'), + ('\x5f4b28e2de125cc6a8c6f845ce1e7fe706d0294a'), + ('\x5f4e75aee52d31d19c15dcb0e21014a05c66d7bb'), + ('\x5f5094dcccd47d76ede850f3a76fe1a6349d96d7'), + ('\x5f59f3e4395bfd14966e0b0fb3cf5bc780b2b88c'), + ('\x5f5a0e9158dacf4b2e2c9d716559795962d86229'), + ('\x5f5bc2123547c3a6c687187fd74f01263f38282b'), + ('\x5f6151cc24c384e62a9b52fc24a964863b7acbd8'), + ('\x5f63bf7994e8198ef4a2ad9de608ff72d890bd94'), + ('\x5f63edff98b35cb610f8405913bf7b123bb8775a'), + ('\x5f66d42671b67ed22b6a88871b35b2e2b75044bd'), + ('\x5f67dd120e2c9eb33431d99e6ac79bc694c81acd'), + ('\x5f68c9b9a1d8197590fbc998e7110df4cee6b3df'), + ('\x5f6b70ff4334147df65e025f0f46c8be6ffeeded'), + ('\x5f6d4cf4e331084c41e4e51284ef46bb83324884'), + ('\x5f76ce81caeb398e78a01a9e6fb7c302d0024b88'), + ('\x5f77ef6937d29b6a096791daf7434c2abe10bbec'), + ('\x5f7ce7f1f7ecb64e013ee3b50cca5e5e61827546'), + ('\x5f7f4eacb221a7820235ce00feb43876b93bae83'), + ('\x5f7f5944ee178ec1e866b582c40df6de2c5a54b4'), + ('\x5f81f854b1d2c9d0e37a34f2150fb8d95520237b'), + ('\x5f837a271d247db217d23457ef4b90af7685aa59'), + ('\x5f85e1c91c80fe66a54d5336dacb53ff6633ad2a'), + ('\x5f89c19c7614f35792489d0e13303b78bc86a5cf'), + ('\x5f8e12171b3baa55f419bee93929b907bda702ee'), + ('\x5f92cd1dfec9553ff4ebbb26bea76e1a14b59a43'), + ('\x5f982147075ad08691e3b3c53e92633ca0ddcb2f'), + ('\x5f9f017181165b8ac33fcfa3e81e0c6e776ba319'), + ('\x5fa2a7869c186d5b4433c13fc95c8d81a749c296'), + ('\x5fac41aaf7f644c8e4d7e554a81fa51ae3cc4334'), + ('\x5fac4b29fdee842ec8c8d43982d3731ddb783e99'), + ('\x5fb35c06a78d9b983d873d525280a65cb92ec71f'), + ('\x5fb464dc73f9824d6783344dc042283f4ab0f719'), + ('\x5fbe50cb311fa0b4a79ebd2a642f058b2395cc7e'), + ('\x5fcb098f9c3c6fae05b75daae0290e7df4211f12'), + ('\x5fcf7a522a1b6925bc1cd93effedb3440848cc4a'), + ('\x5fd055781eb79a8436a8bfe0d70c7dae9f64e6d3'), + ('\x5fd69fc46e713d5e5d083ce635b01696bb246fb4'), + ('\x5fd7f3e866b0209e513e80a334ad93aa44072f9b'), + ('\x5fd98e29bd66d1225a58d4e46f62e78b1c937030'), + ('\x5fe0535cbdedba01bd836cffdda97259b79f29fe'), + ('\x5fe34f4b581063eb32d462d751f09f96558e7fb8'), + ('\x5fe8dac9f4b361793ea218d7fbde70cfe76c1eb6'), + ('\x5fea1d02c861fad9926bcf6af8f834955e610045'), + ('\x5fea8c9354ac2a713c432b789a9ab86d13242388'), + ('\x5feaf9502ccba476d811150162a1ca6f173a4369'), + ('\x5ff5b2d105c167bdcf91832347648f62550106fd'), + ('\x5ff5e574254251b0a6a3d07d475bd5dfb5a602bd'), + ('\x5ffaff30d6000b889a5b6d5a8c54f671d58379f1'), + ('\x5fff6295a5d245e5eaa27c2197af35fd6d18186d'), + ('\x600046ddd22cfb9ffc5e34f366879a74671925ce'), + ('\x600560bd999044b967b1eaa1575b5deb1c897eda'), + ('\x600e49e77d8e3912819fbb96e60ce6bf8b42d15b'), + ('\x60143853fca75e37cb6858fe08144acef8cafa89'), + ('\x603003a7e65f1a8332f58454de9524867b1ced91'), + ('\x603247a9e286e5ac66b86f3be8ae342992ac6725'), + ('\x6039b2e670922800376838f7b65095b53429032c'), + ('\x603a237044168278701ca4ea4014378df5b17811'), + ('\x603bc138c357acecd300f65a40702fb45a3e5559'), + ('\x603cd6133dcf39336aad06fedde177ff33367ac0'), + ('\x604584f69c6aef97dfa8ef8f6bd5feb4aba1ba03'), + ('\x604707aa5dfc1d4b7b76bfcd9d6ae01b6fb0ecae'), + ('\x60494e0d95adba31bd392cc7dfb6deb7f9296458'), + ('\x6049b0c16ca45f6068c8f9ce52ededd44f5fd33e'), + ('\x604ba682f924cd02a26164195ea73b119e4c5f40'), + ('\x60568c6858353b77c6508a5b415fa377e53bfacd'), + ('\x605d760ec123f99a3fbe73240663eafea8e7e64c'), + ('\x6065f93d3e094ca966eb48c041e1f21bac566896'), + ('\x606683871d14e966da3aa807cab0ad80906d0a04'), + ('\x606c07e23cd7af0b0b6299aec9c030e570e482e9'), + ('\x606c18ce8e140516301e633ecfcafc6c0fc111c0'), + ('\x606f99628653ba11affdf7fe0706bd020886e5f2'), + ('\x607090a2aa0017e649898013c1230dfce1fe6e65'), + ('\x60741efcdeec9c0309e8d31369bd57c29c545765'), + ('\x607454659f69f060b09de4c8fb40668cde8ec907'), + ('\x6075cddede7f664a0e703994b4b7f4bf17b12958'), + ('\x60823b671e1d061f6327083700d8ec58491d46dd'), + ('\x6086399c65463e49c90de5d71b61e70addfc80a4'), + ('\x608a938263f2c89f9e453643094bcc1838cd1e39'), + ('\x609209fabe16751e50cbd541e9111fbb1e12aca2'), + ('\x609efd600c9ad2bc8d3961755e5ce5523a92895c'), + ('\x60a26b539137498c311177ee3a937ee1162a1d8b'), + ('\x60af85b4777b088d0f5a3d74bfccb89c60b474b2'), + ('\x60b407e1da93cbe02ef770172fda5978435a3a6c'), + ('\x60b5c6b8bfe16a5285ed8c13f1c294d5178f5fa7'), + ('\x60b8b9ec799b3bf533a39c57a9d452307658e096'), + ('\x60baf44a1fdd83eb7b5d81c069c2855fae15cdda'), + ('\x60c8fe749a912de50c55609418363b2b00ae3ef8'), + ('\x60ca0d33e175e707778b6ed8ee73076aa61ac939'), + ('\x60ca7a614493a0f5dc9b7965d6be33116354f5b8'), + ('\x60cb87f173eb3ecc60b6c05e8c123ae1da00b9bc'), + ('\x60cda23cb0a56eae58653831179dc345b8ea0bf3'), + ('\x60cfb57507f77a547d0fd28349ebe36ef165d580'), + ('\x60d06eb7c1be561ddd9af11021464e106d7739c1'), + ('\x60d6f18d95be593002ba5b5e845527fe706eff53'), + ('\x60df242e855c488cc6ca549e0b4b3a2dee21db10'), + ('\x60f0549a1b6462fae6168bf77d90ba683e99abe5'), + ('\x60f057992dfbba7d75696effdee7f0cc152781fa'), + ('\x60f644549437ef9fc09e2e5f6f8fc19fafd5d35a'), + ('\x60f8487cd46b50b580d1cbfabb7c492bbf4cdaaf'), + ('\x60f9982797be02c969e30f5d762448a1ddff10a1'), + ('\x60fc2002f8a51d15028035d7f9f270184baf191e'), + ('\x60fd9e1a787a0337d1ed7b9843348b2157de051e'), + ('\x6107ab5a6528ef7254d0876f9a9afe502563e566'), + ('\x6109a59ebe8ec453d3b1c22ef501eb13f38ec11c'), + ('\x6109b4726017ad70f3128d47614a9448aa9f41a1'), + ('\x6109cb74813fe38627a9bced883fffc2852f0747'), + ('\x610b13319bdcc3b1bbb89e4fda31466d9e60e931'), + ('\x611203dd215cf89729da2a34a3b823720bad5bf4'), + ('\x61145c30c255313171124c4c9e4fc61f8a9f9525'), + ('\x6115b45bb4132498926aaaa403f4a58a6975cfb4'), + ('\x61179d7ab103ec823a852ae9619cf19d4de35bc9'), + ('\x61187001348299dbf0c2c0416228129a701b47d6'), + ('\x611ede1a3bb2e40db86a8d3db195e75e556747a1'), + ('\x61212e91f7691a622ad21c54ae25d1152f3eceed'), + ('\x6126b7745501f33cea9ae2cb1f1f5615e0eea8da'), + ('\x6132f7029b2594ccef77fa88e895436ebb33815e'), + ('\x613323689532b38b97623378469bafdf33f285aa'), + ('\x6136e91be4021b9168d0f2916719cd6314ca7686'), + ('\x613771cd31adc0c449f14748182f9bb3eaa52fb5'), + ('\x613f5d0bfd2f11bdc0edb6462e45b2ac5385b142'), + ('\x614bceb79db82090b1222d0cb70360c3555ac51a'), + ('\x614c7b50de5b4b6f8b445ff0daca390220f24589'), + ('\x614edaf5f742fd17220773f8c9eac6945de3069f'), + ('\x61513348e60ea231312c90667ab9cacf95f4cc90'), + ('\x6154e4fb79e48e4f3cf1796a419555e654074f69'), + ('\x615802b30a2f90b1de799c06532d1bc0fb0fe2ab'), + ('\x6159139690f817e526261cae41ff155c607afcdc'), + ('\x61607fee487a5c757efdabdb15a880ef409443be'), + ('\x61678c79e3180bebf8e439c487b1dad383192c7d'), + ('\x61687d5a99b8c40a1672164a071c2735fd3a9d10'), + ('\x6173831bfa4dcfdbb4b1166a6da232fab8281076'), + ('\x6173e24fb02d1d5a2ab628e452861f41141aafe3'), + ('\x617478e96234a68c72b44f3a9c36c9eca4b6a4a6'), + ('\x6174f38b6ae6a1832f6edc2e47889aa7084b5bba'), + ('\x617605756a8e9a6f7d3b7bb39c49303e6346b9fa'), + ('\x617a9d0e2a4bb7e22140daf1dcf18b2d2c81cbeb'), + ('\x61840b09f74286f3b0a1f4d6cb40ae9bbf828551'), + ('\x618478b488d95279d74f03de76515e9b60c86624'), + ('\x6187c9d799c01855f1a0feac29215ac7aa871a0d'), + ('\x618b4b02ce5cc1680cbbb97acef6d4b4271b0c55'), + ('\x618bfeb7acae9374903550cec48ead92749c74f4'), + ('\x619073ee242f10fd1c3218b3eb41dad3850bd919'), + ('\x619111e3678b00de65cf6f8a79aa2e7ac7495af1'), + ('\x6195103d4f0f3b429ede76298c8f1e98b2c24acc'), + ('\x61990d1f7f5b005c47dae97947df6adbdcb85190'), + ('\x61a119c3b88c0f5ca2357898d913311ac8d0a5b6'), + ('\x61a47c31b1ba15941dafb26447510ba78641ab86'), + ('\x61a5dbb82657f93b5aaf649fce03ec2ebeaa66fe'), + ('\x61a78cdde5064690e2637b0ea92f54e7a60a7f04'), + ('\x61aa59c3dfe63341673d2788f2296f1a251d0623'), + ('\x61ac867be0b39dd32b0c8eda0b34e6f10ecfd882'), + ('\x61b41e1524abafa926b5ccb79333fad1d8bc3caa'), + ('\x61b816c6b3f6cc56965c12b71526157b5f89abbc'), + ('\x61bb4c208d6d04018e4529ec7ae67a8825fa4ce7'), + ('\x61be536297432043b4971735a455205d34c06297'), + ('\x61c4cb15de29ede9b009cd96344fc1eb72f60d75'), + ('\x61c8c591e922cd7581cb1f2ea54ad05fc43db60e'), + ('\x61ca24407d293245dea2f63ee88695d5d5e41e2d'), + ('\x61cc48087d3dd21fe43ee9a75df601edc9615b34'), + ('\x61ccc3402740d51faca6ad6e18fc93684c024625'), + ('\x61d0f5b27d4991ffcadde7d028f75f433792ef7c'), + ('\x61d3f3cd8e8350bb208843ee9d9bc45d237774ad'), + ('\x61d3fb95d4d5023c061b38bd3097d12abddf77bf'), + ('\x61d596305d33bc93cc9b0626b3f08dbc8f755e70'), + ('\x61d9660f45130c34a95982fe9ac24e879fc804fb'), + ('\x61e227a73be00ba0a0282ce232f188e8c07fb2d5'), + ('\x61e2d0595638f6357da0d6ce3558928f276b3cb3'), + ('\x61e915ae6749a6afd9e77d530153aad08010dce6'), + ('\x61ebd848dfe1be8f0bfc21e32fa169d9d49a5ff6'), + ('\x61ec2a7afdf1d25decd646b357f103c8bb3174c9'), + ('\x61f2f0c14b159cb2da4f74e826dbb2040f9bc2c7'), + ('\x61f62eab92ea81cdaecf18046168f6e6f915dd1a'), + ('\x61fc9d8ea838dc1440a0c8871314d52a0e9bd6e6'), + ('\x6200183539df9934e70634f15092b57b035083f5'), + ('\x620211fb5da752ae82ae0c81806583e2a2b4b148'), + ('\x62046bd01a00fe7e48211480fc484f4543061c8a'), + ('\x620aecbd9c0ff6b63a6994cb7598dcc6c3cf2506'), + ('\x620d21b6ec417b414fe13291bb6e483856c8d826'), + ('\x620e2ebffd7dcba3f53a567d00075fd9b4bb33df'), + ('\x62141cc7307b2f3b8d6443e40a33a34a23a93337'), + ('\x62193f7075df61b46bf5e3ac6658284c40b8feb2'), + ('\x622d5b267787b288ef95c4779216f1b7e5a403fd'), + ('\x622ee4d2d13a1f33280f4cb5c90318b1df57fdc2'), + ('\x623072d9419704e1cd36d8df7b59ae459106b365'), + ('\x62336b61d3e386efaaefa14deb25febc02ef5584'), + ('\x623b8fae23ff1b3c5f1911a66d6d6952fa9e1301'), + ('\x62433ecf2fb7fe62abd8fccdba98b9b90e68b88d'), + ('\x624480d1309e69c8e0060f1e4bf99ba703b02900'), + ('\x6249ebb6bfe2845e497decc0a183819df1d08b9a'), + ('\x625b0147eb8008412cdd5708fa1093b48e326365'), + ('\x625db7c0d45a2686f460b58308a0f0a9ce323c91'), + ('\x625efa674128899d9c0c64fa559bf8524f020f76'), + ('\x625ff8fa45b772c962fe22628580b0de7072005e'), + ('\x626037fddc791b37034aa21bca992a95fc0cc47a'), + ('\x62619b629eb3707c85c365700df6c2fc7e2f6c33'), + ('\x6262efa8d02effec5a6274dcefe9652e0df697a3'), + ('\x62685997f2ef3a4c8fd2257e5142c0e0debad523'), + ('\x6269a9f7bbca5df38e1ef3715e23619827fc25fa'), + ('\x626deb694a865b7e20a25b61ea1cef79231941e5'), + ('\x62703ab7c1d0db73d09e46cfb5569ee966191fcf'), + ('\x627190bf3f71b72a7346adf35e56494e7aff4ef6'), + ('\x627e11924bb5044156d407f3dc342c9236431b3f'), + ('\x627f258bd63c15f7e7b25393afec9188033073c4'), + ('\x628669a2fa5967586b518e9de31cefbc59277a52'), + ('\x62872c2e4abb540011bb75e2e3bf6c538df39756'), + ('\x62876b45e1a7b98c2bf5540aabc1461f7b666919'), + ('\x6287eb467598c022ca5dbb0253365ef02d582ef0'), + ('\x628a81a1bc266c230759c7100962152af3971e28'), + ('\x628d76ec3d7cc830d716b33a2579a9ed13914c82'), + ('\x629171ee37961cef8958bdd08d68c6016f7036bc'), + ('\x6292118ad9ccf0f94c0a09a8cce24ac412303916'), + ('\x629607ae22fc2bb7f215cabbb8546d0d149d44bc'), + ('\x629831d90ef8852e9041e5e595074d985b3325d9'), + ('\x629d6fb91239f4025c5bd2b03f8db19223d4a79d'), + ('\x629dae5c4e4a2c91e825aa0627f048e210d263ef'), + ('\x62a482d0d6a3ff6ddbcc52eb331de7ccebb83ec8'), + ('\x62aa2568aa4cf2bd197aff459e59372578fffab3'), + ('\x62acb29ecd2bdaf57992737b6821826eef3d07eb'), + ('\x62b00f74db46acd54c376ce52a8d12dd431af5fb'), + ('\x62bab749371324680e9b26dda45abb01f09477d4'), + ('\x62de46881a8b6ea3915f3b4f60563b238fd6e414'), + ('\x62ecc95f854d4ab2452adb90f57cbe62735e7b3f'), + ('\x62f227084aa260ea79a89971699d1720ae937de9'), + ('\x62f97bb15f8c701885779f5d5061e24627869e20'), + ('\x62fb3719106f09885ed1ff8b7c0c7378dd8172f0'), + ('\x62fb466f16d5db30694fa484b966c63f28343037'), + ('\x630569d808c63846cdcdc0649143babd76493bb6'), + ('\x6308945f82b3447b0650f51c30f87856f80fb18e'), + ('\x6315b5db711f267dc9b47adab3a920b22937a0b3'), + ('\x63164e1cebe56f17c5c908348cfab60020b32341'), + ('\x6319d1b469efaa074506d7d9406bb62d53aba23c'), + ('\x631c4b9f382d587aa0d489703abc071693d062a9'), + ('\x631d1ecad61c9f6cf5c9798631319a2afa81e9ce'), + ('\x63228b4eec907a9a768fef10af63f21880023507'), + ('\x6323f73d2e628f53918b49a53be086710050c653'), + ('\x632698199cda60a5a53c8b2c3009340d54f74c9f'), + ('\x632de9822549fe246e1325be5863e427adf2a0a9'), + ('\x632ff55adea0d9b02b8044c00339c730e8dbc98b'), + ('\x633185aae3796340a95cc7f95142db3acb1def5a'), + ('\x633237e8ed29c467a96f279f88c9d30d90a79ba2'), + ('\x633449ed5424451b462f5049320eaaab6159c93c'), + ('\x6339c1e2006da3fb6adcaa6b81e2f3598ba68349'), + ('\x633d0821977b0a01b439e46779aecefb1b849d1a'), + ('\x6347b2a8c04d27c568ad6342622620ae910c4562'), + ('\x63541cbb98638b86bbc1df2d09f4eafbe3233a42'), + ('\x635920bc8abd84b54149a43380cf6ab61c31dfa6'), + ('\x635b164b7aa20dccc6428e3d20ce5e710ea3d26d'), + ('\x636c182b557e781f9cb80bc855396405e5b0fe78'), + ('\x6373f53e80c2fd8f493b031c0ec4a2ae5792ce8f'), + ('\x6386dd5af9a9f5c276279f1f05afd93b39678839'), + ('\x6387446c6e8564bccfe8dbbea6a14131c486c393'), + ('\x638da948c8ceab71f6a18f3577a5ed83a4bec83b'), + ('\x6394b8e7b4739c365493e3b6a5d3a1292aa2fac2'), + ('\x6398d197de16f8a1982475462da4032918feaf30'), + ('\x639a5b4bb5c66af14f287dc629cc2ec477d13181'), + ('\x63a424f7b3379557686f8776e885ce6cae5eac33'), + ('\x63a4b3599c6919dd3bebeccd2c62e5d85bb8675c'), + ('\x63aaa1ce4496f60b01bbcdfb5e589caa02993c8b'), + ('\x63af0501f96b003a343210ab601f04141ea0fd30'), + ('\x63b0b15fe1957bd7e70d3fb60af0639eb85728c4'), + ('\x63b21b5320ed9089e4cc00da7f2f7e8db624b668'), + ('\x63b459c5911496b239129a03905a4f7c45b46436'), + ('\x63b5f6d8ec2be386dcb89e932616168f57d8b900'), + ('\x63b69ec7adf0be3be3b0801f6bd5d88a425eee90'), + ('\x63b7c403d1f572cc1ce36144e927ed4a88e2974a'), + ('\x63bd70bca9b311484761bc531a487a8955064882'), + ('\x63c5c693916e96427d684476ce392f2a53fae77d'), + ('\x63c6e799e80487eee8a8a922d627f239b1968db5'), + ('\x63c952aaf19214697145dcb8f68e9307b0c73bc4'), + ('\x63cb27dae9e6bc5762df5ce435664f2495daeca5'), + ('\x63ce284ebba8c651bc4d3d4ba56fcf877fd21109'), + ('\x63d530249b06c18f851e35f73508b4826e568581'), + ('\x63d7ea744d1921dad7d25f960f80dc0f90d6cf69'), + ('\x63deb57f1a519f40d95664024d1cb1269a80f6f6'), + ('\x63e338fd6487deef133c2b1c18b99f42adaa0164'), + ('\x63e3e16f229e1c5a3bece2ab717ba44d8cabfaeb'), + ('\x63e6fcf05510576f14529f66c3d4959c88e7e65b'), + ('\x63ed7979a6e4aeb48c227f5e67a8b3aa265cd813'), + ('\x63f029091221e28d75e55b903f968dd21dffba2e'), + ('\x63f2941d3b7ce75ba1dd0a26118ccb7e24283140'), + ('\x63f33065a53e0477beaba6b503e014b6a975b719'), + ('\x63f3ae9cf2bb777a87281611b223ea30408224bd'), + ('\x63ff5eba468ede591be0ae4dc28de7bbccaae048'), + ('\x6403e812059a726410ec03a24270570f97620684'), + ('\x640897927844ae4bc68b9b2905ea2454893d5940'), + ('\x6413c512b4642a9fb8205c77d14cdb1ea5ad0e99'), + ('\x64152e29b649c114c8b91fad7ab11680d3d3532f'), + ('\x6416a9a82cfae969065b2abb342cf0f3f76a6a2b'), + ('\x641964e050c8fde306119892bce81274edae6275'), + ('\x64196c07dacd58162b5db4ed7cb975d89e02ebda'), + ('\x6421442509cfd3d98ac4b1e735b66d4baace69a8'), + ('\x642239622586f6c750f8c52605c5e250a0dbd206'), + ('\x6426ccfcc26896734d327750337733941d585329'), + ('\x642aaec1547fc48f4a017866d06e0d0f10a61dd0'), + ('\x6443204b43279c9b99b1344163cf4be18c4fb2de'), + ('\x6443822c780fb59b947a3337bc0cde64176305d1'), + ('\x6446b4006c76ef9a530028865e3cabf6dbaa9a2e'), + ('\x644a66528d5f9ec6e8226793b10c0f67ad2a6879'), + ('\x644f0610c556b354632ad2c5b4df23c16a1ffd35'), + ('\x6452dd78a2cdacf40631d7ea41249a02f356a275'), + ('\x645c12a95690a0cb3c91a6cc59fb76dace9eaee8'), + ('\x645c9acaaffdefdf325ac3eaf84705427ae3f54a'), + ('\x64640d4234231e9b6f15053c0705becd55f17a13'), + ('\x6465c8a684158d9bc8b0cfb0e152b62b464288d4'), + ('\x6472e4395de1c9107c39f4a94e8afbc60f750380'), + ('\x64788f610264a8820506afd2f8edce0cabf96915'), + ('\x6478d72d8e1ff8dda3ce88cef04f98c3450adf2f'), + ('\x647e4bd207a5128edbe0de50eae2ecfbf44bf978'), + ('\x648bb98661b493030d660b51888039c863a41033'), + ('\x648bf4fe9436feb6e9b64490a93126d9b2ec5ae8'), + ('\x648bfa7e2c45991df7cdb5861c968201c9dddf73'), + ('\x648c9b55ead7843b6a6cdeefa2d677ac9a11b8a7'), + ('\x648e64535e45391fe0ad659bb33cde70586c5773'), + ('\x648edce93323959157ad1b159c590fd81625fd29'), + ('\x6491f9640797d82fdcccbb0e0b3b4d30103540f8'), + ('\x6493b0d888a81f39acfd0440ae14474fe6dde378'), + ('\x649a3475fe5343855b5569e9b0c510ed14455c9a'), + ('\x649d79158801eccffcb2a0c9d90b115031a3fcc9'), + ('\x649e784f02ed4acfa7e28d8078cab2c68f5f2bb0'), + ('\x649fdebfed212f7832eb786d7455c0140478193c'), + ('\x649ff0e2e1c66bc03d4df9eb6f956a72d1a71616'), + ('\x64a3490c44db587a8441122f225055f6b71e5172'), + ('\x64a368ddeddd0261124b627ca6bc355bdcc16ebc'), + ('\x64a724965a587d750dbfff7eeb9d468954056794'), + ('\x64aa31c397b0761aadf88f45d8ad2420df25cf57'), + ('\x64ab31a89f9e7f4fdb529c3bf5a591ea91bc34d2'), + ('\x64aef74f9ee5f6d188dc21e4a01da5e7e177e482'), + ('\x64b33ff675e2651b556bf86fa743953187a4708c'), + ('\x64b643d0702fff96be4cc0eaf575ea05ef9f5bb6'), + ('\x64b7264e1e409ec2e165dee84b2def4513e414db'), + ('\x64bfe2bc61be006e1508a2ea762b0f79dce0b288'), + ('\x64c43ca8c4a9e4b42cbd44a3ee1c0ddc4ada85f5'), + ('\x64cf85e1f6b810242897f0c5657812673ca65407'), + ('\x64d5d2cefcaa9824a46d70163e3148d6c3bc09b4'), + ('\x64d76a0237ec73941566949dda2ce24dc59ef327'), + ('\x64d9a714f7f91741d1b72db6e2d676939da1fda3'), + ('\x64dd43bdbbaa56a93ee404cad4b81c1e2b276915'), + ('\x64e1bfda9dfdacd87e192edd899e9dac1505b04b'), + ('\x64e56297eed56653d2b98a6cb0f457e4eef4fcc5'), + ('\x64e72909e7dbb87baeb9f222456d2b00c80b21d0'), + ('\x64e854986ce58f238f285a1e891f4822e6ae5df0'), + ('\x64f258057e79bada805f802f2355faf4af16f0c0'), + ('\x64f7f379d761af299281aa8a6312f815e467c2f9'), + ('\x64f82bf9db8d43d7c6539a51f5d4ee7f7c40ac57'), + ('\x64ff1164955db185feb6326de9c9ea16276a5fba'), + ('\x6500a8b7c64c5777d59439fa33a9ec188d36c5d2'), + ('\x65046d6ca0cc83ad64ff343fb18c028e95554194'), + ('\x65061ea7f6ecc2577d7ffcef8f543489aa1ca6c5'), + ('\x650952296b82a4b0a0351f09f7b48f7111e48640'), + ('\x65128789e58f9416e629de3dcdf8ab679d6aed90'), + ('\x65175a6076d1e9f08ce19b541228a030edba2a5f'), + ('\x6519c48c7a4b9d71f22a735105d02c65c5c19462'), + ('\x6520538d4c4976641b64c1363e8708a0b977fb86'), + ('\x6524805d4750c2f0c9036d1975766c9c285c5f2c'), + ('\x6526f8c1b3f879abb062d8a25dde93197cb126be'), + ('\x65285c75f218edbfd96b9b1f1c37a8370b1f3ef6'), + ('\x652b6a6db8ab112c2a53c60a7af8a6b1c87f761d'), + ('\x6532c2dd5b5280bd1ad5a1d2076d954ea0be6157'), + ('\x65378fe083e29957f0bdf2c345450ec7f5d6b006'), + ('\x654862ee9ee2035513858c68763b21fcd3013aad'), + ('\x654b5e15c80145fbe81b5510b5238287153c82cc'), + ('\x654c8e1413d86d41a351a015969f1c32359a84f0'), + ('\x654ca6f41e62781e88210c2d6055fe1495747e24'), + ('\x6555ed7f92b1ec2a6e5cfb98f0aa06a91d3f3d87'), + ('\x6557de800ddd51279e01d58e4fca69805c7941da'), + ('\x655c3816b5b30cc0f3d0435a16c1eb0c4d996ec4'), + ('\x655c81b3678991fecc5872a16153d28749c8a81b'), + ('\x655ce23abed1190dbd1f6a7968361332f5aa8d06'), + ('\x655d82f084d83dcf4932c3bf9adc6ad5f014ff00'), + ('\x655fc006e7f838b293010fd893a977eb4a85627c'), + ('\x656072550535a696bc854e34fc5557388da5ed29'), + ('\x6561ad276d4968d6baef4650c89de975844f3c8e'), + ('\x6561c0cd81c735af650d880c81acc5e51ac616f3'), + ('\x6563dc27a4b8479bf185798c991a6ad405ad6e51'), + ('\x6566a4bda8f9bf1420e2a02572bea75eb207ebb7'), + ('\x6576fc43a71c1b08fd702691be624c06989f3a03'), + ('\x657f9635454333a41f5fedcd2aceb5e2b0bce1be'), + ('\x65818669de3a1b92a32de0534f7d372600c8d99a'), + ('\x65823e3bb33fc7e306bf50f8ac358cdef9d4ffef'), + ('\x658644345a98565daca855c7c65c369a43a0d08a'), + ('\x65882d23530235b10189360c6ef312a8ccf7d6f5'), + ('\x658fbdd532cc8cd2f4658cb2b71c358d4ccf6fde'), + ('\x6592eb3d078e6ccbb24e39bff863798663f4a90c'), + ('\x6596a26b391dee2bbdfc0a8ecf9131191505ab22'), + ('\x659c491edccc43c36c58a4a8fe5c3535500b1dd3'), + ('\x659e0d0fc3428b27f514d4c2cec8b447dd54fa21'), + ('\x659e3dd958730142ef0ac3b3e3913582f8987590'), + ('\x65a0717561fec49b3381af96e671d4e2a4b8277b'), + ('\x65a217262c4041aed85447cb1727b1fe1dbefd5c'), + ('\x65a2608b69c41254a1032746db10ab4d18ae3b60'), + ('\x65a94c1c490823c16e4fcda1492a47e3ac3eb269'), + ('\x65a9e80b1e27f3b416aac87062567aacf813cf8c'), + ('\x65b585c44b152c37a1fb6a32b3c3aa6dda153473'), + ('\x65bb68dbaaece7e5d639221732c7461e25e3252d'), + ('\x65c1f1de801003da38b2f5893935ce2385f08997'), + ('\x65c5ca88a67c30becee01c5a8816d964b03862f9'), + ('\x65cf874296a814aae267a2a9cd40d77f5a7b0792'), + ('\x65d24a9507582709b4d6b39f8b00856981c61da6'), + ('\x65e04f6006d5cbfc43f718b60511cd5b2233752c'), + ('\x65e400c6d50880226a47ccba369e5826e8bdcf98'), + ('\x65e57914663564bc0a73acc07dbc790842c4c540'), + ('\x65e6df1465f97d1bf945da74ed246728f0fe728b'), + ('\x65e8fb8a5e5e5dcc68b6c3f2f610e433ff290d5a'), + ('\x65e9aadad0bce7edd981e47a09990759a7f54348'), + ('\x65f14ded6e2ab5aa462da28e32a31dfad29f91fb'), + ('\x65f9e8d403c3deb1d727ff5f0484b41cb992feb0'), + ('\x65ff34733315fae0de4607802237b33d2c1411de'), + ('\x6600c863197bae8bd565e1236827f75d268d78e1'), + ('\x6609043a107d3f00655bd8546a86bc79fe298d1a'), + ('\x660d961218464eae2f48b3d39156e7f26b07be3d'), + ('\x660e48f09cde5324ff22b00724233af46dd561ff'), + ('\x660f70fc100a3f157e8c435f6458755038122c04'), + ('\x6615a502244d7b9c95739476ce72e2d623ec1987'), + ('\x661a07bdf8440b7bfa34b38373ba0d35e4e68b89'), + ('\x661d6020a5dc0a03e924584810f1c792373c0551'), + ('\x661f79496496ca07afea07da16d4141ba65beb08'), + ('\x6624819af7677183497bcc5d7de46a4a5947a1f8'), + ('\x662601197e03a32a37234c59b888b7b5e5f70cc5'), + ('\x662aa6233571f46dadfaa96367bb4b14c3c3fe4e'), + ('\x66393a5420745a06d09ff99aea7b279da485e13a'), + ('\x663e191f07b0fbbed215b0e7c62ec5647f5e35ad'), + ('\x6640f3ec34e1e0e8676a7c8407eb55d710855288'), + ('\x6645743784d57d7b29293753d7750bf099a72208'), + ('\x6645da18bbdf50a47c557de7fbf76349a4c1bf78'), + ('\x664e1b2f9dbafbf6280305123d2df7fa0c66cee9'), + ('\x66581890d84389bec6805c66c9cee6cef77a60eb'), + ('\x665dbe97203c2d60e77e72dd355d7f7c540f98df'), + ('\x6661f5d1515f184eae6d2f1613a255f3044574d0'), + ('\x6661f9ba60fd9cf80221408c46692355b8a5b903'), + ('\x666605dc45e8eb6ba73fcdb25c6638c325dc7662'), + ('\x666852639cb81a6a431a32ce8c33311e1e4e8314'), + ('\x6668addc6800cd9dd329d3d6ca89bb1121318751'), + ('\x666aef2912b68284d4396d4ddd6099f83939fb70'), + ('\x66716370fe15e1af51c88591bb6b381748117062'), + ('\x66722138fc072c4532df9701346fcb11c9bb86cf'), + ('\x6675f8086e2cf2248b4173d5e9397aaa0bf7f54d'), + ('\x667e9c667af6d5923d98f63657ef1b62ddedc1fc'), + ('\x66846bbabe4164cfdedfab36073668b645db9bf0'), + ('\x66852883f04272f23d85e2c5ce7d81676b9d7313'), + ('\x668f533b44da1add568319568737d7a7e32dd610'), + ('\x6696a2a6b8b3f653946733ebfad10b618f148de4'), + ('\x669749068f2e6fbd87614b1572d137fbab9bc699'), + ('\x669802418f63ac873f8724f106d04f76437823ab'), + ('\x6699583d15d78c80ea5d946fe2b135a5862a5fcf'), + ('\x66a464844b311b556fc13c9b98ddd90ff5252f29'), + ('\x66a87b3a5491ee3667a1c8ded0c08865378e71e1'), + ('\x66bd90f6e7ce4f2cdf2a66dcf266cfffdbddb382'), + ('\x66c9049efb45bdf5301fb53f26f79a9451b598c7'), + ('\x66caaa9f316b074b9304e753c194d6b6a3bcd7b5'), + ('\x66cefc4a16d5981726643be1922559de4e80c5a9'), + ('\x66cfb29914e8bcd68c855e07f35f147883ac1f78'), + ('\x66d3ec7a35f85168a8b5379851e32ef357d071c1'), + ('\x66d8e004fef3fb7abeecea37c9753a3fc1b040eb'), + ('\x66dbeb39fe7a755b1611efd274e8fab80ab93b94'), + ('\x66ddb17190a89581fb5c472cb27268a9f735856c'), + ('\x66df53578fb4e816ef6f581cd8b4dfde300c96c5'), + ('\x66e0075526c44db0ea967701c5d25a086c44338c'), + ('\x66e13db9583ff474b600b74c55359e6d24c2e2a8'), + ('\x66e15f0c4b57c90f8275e60e412a5dfdf791b20c'), + ('\x66e2d90c10c4d345fdaa64c9c2e332dca537b87e'), + ('\x66e8a65009f27f1f4d1aa8eecdc7fb6696cd2cff'), + ('\x66ec8e66e682e946cd149dc5829a095993e1961a'), + ('\x66f0b27f770c991b379a01e14da3f3641bfae209'), + ('\x66f354f624c7b610a56d306458208ed7d63e14bc'), + ('\x66f9b1b79a5cb5b8413b9c9c48ba64916fe7838c'), + ('\x66ffdc36fcd19f758e7614cf2b0b533785aa1928'), + ('\x670faab26ef92cd2a26602afbf2728d34b7b4c24'), + ('\x6719206dc7e0eb8d23aea3e9b90cfcf6627db672'), + ('\x6719a2565412176fd3eef771e32c7c244516e779'), + ('\x671a6913162e43b70045b0948974cda793edea6c'), + ('\x671c2018519d76106b89d4fe9c6ee7691a03adf8'), + ('\x671c3f190b06e75b2cf977a159e5831442115e25'), + ('\x671f3d8dd9e21abe541f7e59d7293fd7fa314950'), + ('\x672028e8634a1b6430204af3a2552ab64665c7e3'), + ('\x672214229216b6ad061d2bd2848ed970d478d8ca'), + ('\x6724069786c9ca661394aad2d6e397e97e0bfe18'), + ('\x6732b61095775e7dcc74cc281ecfe71e37dd7c04'), + ('\x6733bb2c3b2d163d4c944e5ba4340a132d5751ed'), + ('\x6737fc02d1f56059983cc8bf5a01610fd9b55287'), + ('\x6739d02380879a3918da56c71c7416dfe3feb0d8'), + ('\x67420778f16850b21dc8a84c7ec8189e7739a0d4'), + ('\x674944472e6a7613bc6971136f864138efa8c328'), + ('\x674d96db260199f4c6a55d8a5f72c8d132a7d05a'), + ('\x674e136dba3089b53097c921c7bb7e279aab660d'), + ('\x674e14c2e4670ab88bbc8f647a088242f8a4cd9c'), + ('\x6750b9c8c5bfe74f263fbafc17fa2a5845b66ec7'), + ('\x67599f7fdae307109561a8fe1935146a38bade9c'), + ('\x675bb51aa4bbf91adbde78f0351a2af7946fcfce'), + ('\x675cd399c6fc15b4799a392690f21d7c23cc9d68'), + ('\x675eaf1d0e49d433994b53bdb45c1d5437b270b3'), + ('\x67618e570517178fde49185a2811e34f735af7a5'), + ('\x6761e747653699133a19e214e10404c3bec093f7'), + ('\x67697bad47a5ce6daffd790b0eb0b51c60ad76a4'), + ('\x676c2f74875f9a6a36c569798f47e68ba9145496'), + ('\x677c628633aacfb89a6a52ed75e9479e8e432410'), + ('\x677ed2505eb1de963f1f1a11e5a262bfe2f47b8b'), + ('\x677efcb2dd136014627ae89fb5210d70fc3a48f3'), + ('\x678a2cfbf07bc64b98e51d9229c70cd9e49add85'), + ('\x678a7aa0b8b4c73aef5a33447cb61d7efecb18b6'), + ('\x678c48e37b5bd892056da72528ad82edb63c008e'), + ('\x67958d9b331f9dc80ac1b904f0e40b8950ea2ae4'), + ('\x67960ef25d4dbf90f462fb0d67f387d8004f89e3'), + ('\x67961af60be9e2b18a2a26bca5dbdfaf5fe49a8f'), + ('\x6797dcc890a280622a39dae7711ed9c02e7d636a'), + ('\x679e45ae67923412be45099a0ac370352952721b'), + ('\x679f5adf65c6745bee7d8eff36c8e64f75e99904'), + ('\x67a0de0d44dd7fee790a9dc5a920ee30e84a5f5d'), + ('\x67a1cd168f405a4717af235aa8cbd1296313ed35'), + ('\x67a3480f1736e8efc21f1afa1e57f3a86d005c52'), + ('\x67a613495a1efb5c5203d1c12a6d10207e8347b7'), + ('\x67a6302733b36f3df2ff07164ab73c0e3b89fe22'), + ('\x67a836140acb850a34db366b580f1be555b65fdd'), + ('\x67a92d3e2c310c39f1fc03c6ce5e4b1acd561a84'), + ('\x67acd706bdf6982e247f2ff588c2d2b9feeb196f'), + ('\x67b45488c9f75e4e5cc17fd82b751f390ba3a785'), + ('\x67b60c43cdbd80763cb6f1c572b5ef98f233ff05'), + ('\x67c4f867aac8f8a61d9be9dd8166583abd36ecd1'), + ('\x67c5982a67b9ecb9f2438d173216157a72b38fac'), + ('\x67ca453923a242162ab0a6bea28ba2234b6e6bb1'), + ('\x67ceb605c9fad7af70d5c8a90cf6bda391a9ab9f'), + ('\x67cf91b783d5fa2aca6059561a61c538928ea9e7'), + ('\x67d3b99576333093b118a780032535bb8bf084c2'), + ('\x67d486929f86a441425af06e025f53b4bb36bd60'), + ('\x67d85fa9ddc958dc8774ecfd7253977dd7b9b668'), + ('\x67dbbcdb2ec015c739c3729cdca8c3d981dfc580'), + ('\x67de19ad132f614acd17d4c8c8dad540f19b5e66'), + ('\x67e34a1e90e9da74c7295cf561e5f9f6df23cc04'), + ('\x67f2dbc6fe5a98e58f4be1f827055f691e6cce44'), + ('\x67f4773b40d3eb281326b98130e1189f9cb85fb9'), + ('\x680143a4930ff612ce79ce103710139a30fe820e'), + ('\x68086911bf12d272933975c65ab62fb111e41f38'), + ('\x680be6bfc15df6a6b17e8c1f918ebd71db5f30aa'), + ('\x680e4ef43e8be95bead0ffc803d79d151f074acd'), + ('\x681181997d38ae24e1fc29b1cab88a45ff150ee1'), + ('\x6812eabb1f8cad58c0d2a46bb2b2be0ccf88dcf4'), + ('\x6813bab12359008d2b4e47e692219670aed792ef'), + ('\x6814cecc1d0b820982d9af29e6ed1fca47534e47'), + ('\x681940fb1a81d866dec8f84bdb0c08b40317f2bd'), + ('\x681b172e1308017866f3cd8bfeeb9e986f3003f8'), + ('\x681b3f02b674f2745525bc2f7450607420f52b5c'), + ('\x681bdd4d4c8dddbaeb4d4f2a1f58c38cad92afe0'), + ('\x681e87ee06b39e1a6a1142fd9800433230cfd50b'), + ('\x681eaf4eb00a7b2fd7bb716a23c16cf7d6ddd7ea'), + ('\x68256208ee58f42c568e878e96237d68e985d1a7'), + ('\x68329388b9e8f1d0bdf1347e88ce5ad1c69a34f8'), + ('\x68357cbeab0a252a295b278faf5dfc364cfc2ef8'), + ('\x68424b7023195e1d2151f05aaa7f259b49f150b0'), + ('\x6845cc831110a4aa96441696c9523d37bb162705'), + ('\x68471c6c961bec94c7dac1d9c02a92d961ab5c0d'), + ('\x6848db0d1acf36f95ee7dc0130eab9e6fb1d19a6'), + ('\x684aacf5b4f354722926994506d3737ef62f0283'), + ('\x684d6d35eca1713469d21870d1a228c4d2a3f0a4'), + ('\x6852651a553ccc1114f244ed85dc4df97d653fa7'), + ('\x6857b25341bac0332ca2f3e3fdc838bb7d43683e'), + ('\x68593084154c87d679fe05a141cbdb140b554d76'), + ('\x685d7008f1c0b51b62a1fa633789b5a08d025dba'), + ('\x6865e552d0a39aa833d663f2e4b14eb9ce0dd3af'), + ('\x686b7bcf80e606bcddf676ece6e042389867b774'), + ('\x686c020ba1a30cdc12aa1eaf9aeb7fc9be1a4dff'), + ('\x686d9171e50f0d1d10e1e56d24cff2589d09734c'), + ('\x687135d29cfc97e461555ffb13af663b348728eb'), + ('\x68724569919744f3cd73ce7ee357c35e8c4037d4'), + ('\x68793c3bc249f113919b5e526f1ebc030a86e7d9'), + ('\x687a75474eda0a68057ada4e4d5cddafc2ed1b95'), + ('\x687de7d5b1584dc5a4d81e75fe5a1b84b93b9423'), + ('\x6880ce5beb25dfacc8fbe44edfe28ec1eaa3ead3'), + ('\x688611f44057b0849387220430625b0fa067989e'), + ('\x688a05a7ea64ca3406181b1e6d524d0fe8d29aee'), + ('\x688eef62db4721882b90a8b06a5b68280a3c62ca'), + ('\x689df773038eacac978bf67b2dd0e631182bead0'), + ('\x689e98c0e922b22a52c028f84da31aee1b1cf901'), + ('\x68a244a979db10080c4227411a7629a2db1a899a'), + ('\x68a5ed123494171de25e239283605e64b53b1280'), + ('\x68a6ccde6d7cf740e3bf855e2d335ce1c2aa1878'), + ('\x68a7d4fe92784be1b1eca5000a08749a31105c39'), + ('\x68ab130963f7fa8d88d265ef1fa34045bb0d3543'), + ('\x68aea4741d89ef83d8edb8054d8035fef05bcbf9'), + ('\x68b11e16616eb79349960de4a413d135b75e2faf'), + ('\x68b450543548f771516b0dcd800af11593d49228'), + ('\x68cb624d6c36a68c061a16971d6b328f07a35e54'), + ('\x68cb83c23ffd0454404301876ee5388a62f528c3'), + ('\x68cd48213470003e8f7b129d1304ba4e61b8b472'), + ('\x68ce46c6e31d1d203e8cb5812d36aff2fba2dd1e'), + ('\x68d07a8b45c41ed57b59a99131fc6d4aac6dd293'), + ('\x68d1cb786a0a1372dd11f3397eacbe5d875f5e68'), + ('\x68d32e69b760f06d223cdae2462ae1afb0095421'), + ('\x68e33d0437d8f67c6e39f952816dc3b8d394d0c6'), + ('\x68e342430254cbffa166ced79d1fbcbc1a9b82da'), + ('\x68e47e86ceee7b61b542a07f0bbd9b282d617ee9'), + ('\x68e7dfc1178cb97934d978039ba158e23c86e576'), + ('\x68e8d714e816f01c8787492be30477015f168473'), + ('\x68eda8384acf02fb8bc51c38aace8110227cabdf'), + ('\x68f18aa0aa54319f141f7ca0249cafebcba47c43'), + ('\x68f213cf5a621040900e1c9c7bdd2e0b137f2f4d'), + ('\x68f461e1a011d79403155b1b6c28ece2ff5e4945'), + ('\x6904735a10f4d747a139d7bad9442fc6b6b093cb'), + ('\x690911d3b75b9c9804507947790f27395ce9498c'), + ('\x691c51c8a03a651ee41d4bf3744c7a4d5bb46586'), + ('\x691c56df628523e004143ff6848e4f904eb90500'), + ('\x6920f9a8943d1a9a08a8fb418d05e67e0d327ea2'), + ('\x692bbbcc1b5f1bac3bd1f38d8a9b9b1031b98e4a'), + ('\x6933e2e8f7aeaa324b02445185af17012ea0ebfb'), + ('\x6934d10efcb7942f6b58ef105a5938d9b1df5962'), + ('\x69425100a383bb6c8ee181f5dd5598ae8e92e867'), + ('\x69466fdf77eb2d439d2d71caac30c2ca0513eac2'), + ('\x694736f47ebc147ddbdce98795e14567cd652018'), + ('\x6949f001faac04d75d7dd4c9b0bc4e7ee7ad71af'), + ('\x694bee7078ed2e37cdfbda8704065e4050367a74'), + ('\x694c99e6c3dfa68b6157aa4856ec442bada0a199'), + ('\x695014b2df23b35dc3af326397a0ec619e098cb3'), + ('\x6955284928efb876b0e7b3fde08f80fb75183b99'), + ('\x695e74fa0beb384d368b0f453a564075a9d17ae4'), + ('\x695f4c24a9ffeac7601294dde774e7cc66182e56'), + ('\x69640e85f33acd4346aefe1c5209d64dd68a927d'), + ('\x69699406ec4c0523fe99b5422cba3fff5acbc8b7'), + ('\x696b535ed3c5950f9f23217a8f6155b1617c80a3'), + ('\x696bd3f5cdf9c4c39a570ed24de88e8e56700778'), + ('\x696d34da6e9e32ac04f381fc7c94cd5a63662079'), + ('\x6971b8bd1bcd58ecfe63c090c04f4d192b5a1cc4'), + ('\x698f6713739507d234f353eac044e7c427748785'), + ('\x69918a9746c542e2328d939bbe3b8e75d1b40b47'), + ('\x6999b5db62879e991847589f09bf2e3cb7894360'), + ('\x69a2e890a6e83e900c3c979c951216bb4e75a853'), + ('\x69a64f4e24d297d02944cdf36a6a469b95a6f3b6'), + ('\x69b8c7f84e850b86c7a970b6a3549243403bf361'), + ('\x69bc5fa2fa8ea4813fc217c2b8c6793edc6fe0b4'), + ('\x69c75ca603bbc54d9f9c10b86324b0c6ccde563f'), + ('\x69c8521baeb04b77e295e2773d459d2f9a2e1ef0'), + ('\x69ca6e5de18ba0ffcf4d720f800b28899d82c3e0'), + ('\x69d303f38ae9de70d9a11ab86ace1a1c9977ad35'), + ('\x69d43539e8c6eb24f9166e7498362a13d2826288'), + ('\x69d98f683d0fcf5486403c3b4d9f8d275c04fdbe'), + ('\x69dade7ea3aea2339e51c9e1d01453b7d5668eca'), + ('\x69df6f9aef65ccaffecd7d2c6f6d6c96c004cbfe'), + ('\x69e2403d97ead76d1dee2265904b15d6de54560c'), + ('\x69e35256288dcca25da11cf2f2a0e394dd96b0d4'), + ('\x69e66073de03319bd30fc3eea8b10b1208dd7fbd'), + ('\x69ef3c6947b967399260431c27601e8813e3f32f'), + ('\x69f068f575cdf4b510a26a235bdcca70ccccce65'), + ('\x69f19a8110bc5d176b3d7937b783de68ee2d9d87'), + ('\x69f6900905a694ecb9e089d2bd2ae966e494dec8'), + ('\x69f8a72fffc881dee85abb2e7d091724d9c1cf2c'), + ('\x69f9726d047602407d06f6fc0b4627408a6baf12'), + ('\x6a03d9cf725498d2252d8ddd8e6b5dd6225dd206'), + ('\x6a17857e2aad083254ce4c502fa276cabf631399'), + ('\x6a1a741df706a095e04298401d089a158a7b7982'), + ('\x6a1cee5b2948dbddf8fe6bb050a5cdca1c206dbf'), + ('\x6a1d60863976e72af27d7e169e874dbea3c4da8c'), + ('\x6a1fa0fa035616916b6ae4faaa8a95d748035a95'), + ('\x6a20314d6b66389f5b5ab827775ea0a4485be64a'), + ('\x6a224cdf46b60fd83e641f718bf67f8215e86cdc'), + ('\x6a23d10c15d3eb7fcb18609be0bc4bf0706eca49'), + ('\x6a242ce5172cfce7a53d583455dde9265ff4d423'), + ('\x6a257b119a9b53bf7d0cc346bac0d012b557c03a'), + ('\x6a273f1f4f938a8d33760ea2fc6c58d1b60d4b63'), + ('\x6a2f415be2d11bff0c65eb1c597257ba839bc7dd'), + ('\x6a2fa2c19ff457c6d05cb0973ba4c96a305b9d71'), + ('\x6a30d44afcd97c6911fe165880cf3276af3164ba'), + ('\x6a328cfeb88252c9b0bed772ab1cb1fcce4079ec'), + ('\x6a34191b710f6cc33b9d3de65eaf2e3efc9cf22c'), + ('\x6a34da5b3a1bd4cd7ae46970c3d31392e6dced43'), + ('\x6a3501bf15977199e45d676c59dd9b0a9bb47b16'), + ('\x6a38a6c3c2908294e710e40a03d5742eb5630df2'), + ('\x6a3aacdad8fdbca7cdfeefcbc8129290d4bfc3e7'), + ('\x6a4cd3900e9ed8091254118037247665c1de2744'), + ('\x6a5155d53de7b940a93c44902baedc07ba91626e'), + ('\x6a541d79c65e88b00b157bd8e053d29f8bb342de'), + ('\x6a58dacfca3fcc5ac44a9b6e05212dd6e6b23fb2'), + ('\x6a669e337f26616f9f21c98f5bbf1fdf845f065a'), + ('\x6a71125d459e331f57b0a5db16abfd24c2b253ad'), + ('\x6a752a746878ea12d38e466b9fe4e79be397f42d'), + ('\x6a76c7c18af9a7c629ab697c42d7a07b4b63ff59'), + ('\x6a7808ccb889a76530dfce15f1376b8dad3df77e'), + ('\x6a785310138d50ed5c61d54aed35ba6a9233d653'), + ('\x6a7921a118d543ba7e45e9462c8f29f67c5e41ef'), + ('\x6a7e3fb5b62572e7d6042aed6e49fb521e2d5ff5'), + ('\x6a85467d5d9fbe83c04697e715814b67fc66338b'), + ('\x6a89288de231e62d23e4025d3493a8986e656fc7'), + ('\x6a8cfa2e5cb475be77f8982ff44323568d8a88ba'), + ('\x6a8da5744c2480f4a3188569eac5e6f20bdacb77'), + ('\x6a912c7a53b404734717cc6c290b7d9a5e38506a'), + ('\x6a926ece1cc78ae8ddd2e4c598c3d924b3c5eff0'), + ('\x6a92f4d3533e4cee6ec39787a2e0e32eb4c0d813'), + ('\x6a9513371a957b50eae98c8725e89ddc4668ab52'), + ('\x6a9ba6c3ce8e915ebc0c18f5a5dad5702116376c'), + ('\x6a9d6e7c14f9b48565d556eaa3e3c9db3dc97f00'), + ('\x6aa033df2c812e989fb871b9f225be6737e13a73'), + ('\x6aa0b0e7b27e963913282c1dfcc677dc5b316bd7'), + ('\x6aa1f99c3dca4753ca862453736d5627a381181d'), + ('\x6aa51a98cb3527bafa5f8be43d3cee568fb08fbc'), + ('\x6aa5b08bbc4d35e449719808d7ff6331eee6c2e1'), + ('\x6aa876a80a2e74a0ff2769224c2abd5a62b416f4'), + ('\x6aa9ac707b76a5015c5c52c922ab89f13e5f7f08'), + ('\x6ab51f6a3a20ce0bad34f2b02e369e5838621c8e'), + ('\x6ab56840c530d369ad27b15aea6f4a8180ccf66e'), + ('\x6ab715835d59f196fc952acfbf74e7eb1fe44e4a'), + ('\x6ab8a0a609b742ef92b4b9f58b8e8a8cf903ff8a'), + ('\x6abb40965bef3b4140b4e078233ba968c39b8f0d'), + ('\x6ac0820e52e380feeadec06cf79bac74ce449d8b'), + ('\x6ac58e4d0c8188c0e4689cbd90621196604a852c'), + ('\x6acaf35723f14c8d09528f707291838604bf9bd1'), + ('\x6ace758dfbf063d1064837c456830dee2a0ba9de'), + ('\x6ad1bb8df4de3a2b4c90af255365501a56a493d0'), + ('\x6adb70284574812078e70aeded179dfcc5704645'), + ('\x6adbf37c6d6404cab69aae71969bab470899343b'), + ('\x6addabb2c27579ef20803236cc7b0b28ce59eee5'), + ('\x6adfe18c940bfc1455ddee637ee412daf3261575'), + ('\x6ae0affec781e2c519befeafcaa27342535d9de8'), + ('\x6ae21ae00b145496d08796b03d23a4ad7294609f'), + ('\x6ae5e3145a13e926ed30be5334d942a2128889b9'), + ('\x6ae62af1da7b6681f607f14326a91e8dccce8244'), + ('\x6ae80f3196c44d0be530d9d14f90178ffa1e6ff8'), + ('\x6ae96a70f36ef21b07ae195e2b53c13d6b19db83'), + ('\x6af49b6cd99deeb0fc9429df569bd9528778e6ad'), + ('\x6b01745425af55e38c0160ae88e3c4b8aa9f051e'), + ('\x6b0a0b7a172b469719284e25f347b5cd3d2223e9'), + ('\x6b0ded0f81818b013c7380423a0c95338ff4268f'), + ('\x6b1571dda1d9bba1623c861749c991f989b78178'), + ('\x6b16e1d8beacc5e5161dea513fd37e73ef35461c'), + ('\x6b1c3943a531551e8c8bab16ea4d2261971d4ea6'), + ('\x6b23f8dec9953e659987f13daaf8c175787e03b7'), + ('\x6b27f54cd1fbfcc413e1f0f482b63bceb49bc9e4'), + ('\x6b28a8ff4ca12c1e87ff07fd495dd2d1e2831738'), + ('\x6b293b7c50bb365c887b6b64e3abe3478ba9fcf5'), + ('\x6b2bf36f3a93d56577555b14a781e5b2c728b577'), + ('\x6b2bfb57ccf663729077f340415dc391106ca57e'), + ('\x6b2f7a9a802eac9d6f5664ed440693bc40ae7914'), + ('\x6b3543517fe8feff046733f909465e007167c775'), + ('\x6b3e0c6240bce9bff2057da3dc7dac990f185847'), + ('\x6b41472a13813f7a18572cf2ef551eb7693db670'), + ('\x6b4205dcd08820a0b95814cd1633124dd6ec712b'), + ('\x6b4522363506503294c4556c1e3049cb7118422b'), + ('\x6b46ce310cf4532230ce0d6db0fbcb6c509029eb'), + ('\x6b472ac599cbdeb597d87236132b836e3455d2ff'), + ('\x6b4ab537188a29293a21ae25eaa68e2cbfb91ce7'), + ('\x6b4e01d6f959e1199e9fe60c068fefcc59755d88'), + ('\x6b4e3d25fc6aaa13f03aaf1a4c126a14b0a08de5'), + ('\x6b4e78b406195c4ede451e78578f71d07c0fd8cf'), + ('\x6b54f4d090458b1746dfee7e8c0f09138580f843'), + ('\x6b58a7859c3658a2b965a9a35ad4b7587a489969'), + ('\x6b58f5f81ead193f771508f079aeba796bb6d549'), + ('\x6b5df1150d0b38868430c8d3b077ba05a36d991b'), + ('\x6b5e5f67285841d0dc1fa6f422b92c1333614174'), + ('\x6b66d358ba6115395aa6c67e6f955c42677b13c8'), + ('\x6b7235a0725143bad59c7b52069787c49d118401'), + ('\x6b756e49d0963da8722c67f11a082f2f77e962b6'), + ('\x6b7848c01e1e2210f426cf310b44254e6edbdfcc'), + ('\x6b78f07ee29abc1da0360055cb9b5b5538a82901'), + ('\x6b8aa70d1a19eaf2db860cb8e1b6d52968a51176'), + ('\x6b8d3bed7a049accb7a591fce07107e6ee00e4fc'), + ('\x6b8f8e44949bd24ce8084653f239a5d3ec5ebbfa'), + ('\x6b9060ccff87f4b016f8343f63d352f598de44db'), + ('\x6b9106646bd4d67937934b61068173bb60c020e1'), + ('\x6b94e72869e3325432f72e2889b08f23fc40b7b3'), + ('\x6b962869602402815d86c7be82ca131d3275f9f0'), + ('\x6b979b41b55c91b9b719267e115207985586cff3'), + ('\x6ba1a5e322079394392cabf5d9cae2d8395ae6ed'), + ('\x6ba4daa3fb452ef1b6388f9f489339c13cfca9ef'), + ('\x6bab4ac8321b6e25ac3dc995c12cd60c6c79a736'), + ('\x6badc65c13507ad4b819a7836d25af05a643aa5d'), + ('\x6bb2af57287277e6c9b976563596a2e635d37635'), + ('\x6bbb240aa2bbc3eef0e572c57f13d059c3f6c0e5'), + ('\x6bbea1b7b4a92c9456f5b6715d9822932b9551a3'), + ('\x6bc36e31aab700d13b938bdf6b559910f6f96dd4'), + ('\x6bca595c00607a6dbf3e96d4666df2f80163fa45'), + ('\x6bcf94e8cd77c33898e39dafb258bacc692b7797'), + ('\x6bd022062ecfc378808f0bec1a1e567a602efa0e'), + ('\x6bd52498e91a4f8dea9d1f911b857b3ade89714a'), + ('\x6bd957ea76b3ab39678b2b827a46049251a0b0d6'), + ('\x6be20ac0dcbd6ec15a331d65e564afe6d8037085'), + ('\x6be3b06101229c452dfab689bf60e5cc6c1e2e9f'), + ('\x6be6703c1f9f20f69183f3509f14804b2dec8862'), + ('\x6becd115a47f933e0e7c23be24fcf6d71523ac79'), + ('\x6bf13895aced13921683c4b9ef8356a2cac6e880'), + ('\x6bf440d66bdfa7970317671e87d20e3956d96c3e'), + ('\x6bf877ef8b070c1315bb451c446ace7a706e79ae'), + ('\x6bfb5bc0893c59100348775fa391d2d1c2951bf5'), + ('\x6bff3f3fce95032ccbf5035616c2678aa8927d42'), + ('\x6c01069bdc20984c0cfbe958e362095772de9c5b'), + ('\x6c01a48f3faac2e3de57f406797bea072a01011b'), + ('\x6c02e4f692a50bd25f573a1af9d9d507e74fca5f'), + ('\x6c0353ed1126085f9d0514e7e590cd5453406bf6'), + ('\x6c03dbfe4e3bfe23a1835a79b9d57de96349f24e'), + ('\x6c07f83819f6c6e1300849190bb27fa04df89884'), + ('\x6c1191478ca666bdec4c72b1f32a86fee57ce5ff'), + ('\x6c16c2d92e893509074ec9a44cff81b5876f08e9'), + ('\x6c16cdd6c74b50ac03753f142aa5a9b41b28969c'), + ('\x6c1b55dc178eeba6f62a378b84ab1403a354316f'), + ('\x6c1b834504f9fbdf375fc0915ca316fe1ca2366b'), + ('\x6c24f535f822033c9ce9db0e100fa10be262efc5'), + ('\x6c27c60a57e8b8ff74fb2615082138023115943e'), + ('\x6c3a9acde14f1578ab85913edc828240be82f614'), + ('\x6c3aac7a1969dcee3243b9b8bd97698a8066c9a9'), + ('\x6c3ed9727df4a70299ada71d969168e230ecf650'), + ('\x6c3fd783e07bc1f403b6b48d4711143ce78daf23'), + ('\x6c41f8a6ec165f69adf00eed5aec8c5b65b2d95f'), + ('\x6c42645b11444704c5011a7ba3e78c7629763097'), + ('\x6c432740bdd46b3568cc54737634d7446b62252a'), + ('\x6c4969fad7e598c2a73c4fb66012d787e67a51a8'), + ('\x6c4aeeab8861308c540e8ead83319cf5e131e749'), + ('\x6c4b1f25912ab83c08118f84e5b0159d11c72b31'), + ('\x6c522f8e292ed4a5ec495a73559eb2cb92c62dff'), + ('\x6c635cddd9a4fc66567abfc0c02ec19aa6bbbc7f'), + ('\x6c66e4c0ae2e36ef803361617c6c78a770257cf9'), + ('\x6c679566ababb148decfd04c0be56c0ed16ee147'), + ('\x6c6a6d29d8ad2a6f7d9ddff146505652f1d32be7'), + ('\x6c727582b6555ca694bff81d938448cb034c0acf'), + ('\x6c73e5f5d45e355928d27413b4b97dc966d29440'), + ('\x6c7e5aa85ba88e6af637bb4d1cd95ecfaea977fe'), + ('\x6c7f8b88800fa674bc9b1481ec05fbf812dece22'), + ('\x6c8567178dc72e394cf100ef3b150a2d3c6046d6'), + ('\x6c88393d567f7d63e77eed8ef191b1d287ef0a5d'), + ('\x6c8c0106c1154086d6bfcd3d7c8a58b241cc23d7'), + ('\x6c8fc650489e95ed3258e3b71f6902c2c4a82313'), + ('\x6c965ea41266ade6b41dcbb1741fc090f6f1724d'), + ('\x6c97f8362199654938a9c092a2e84bce45386f49'), + ('\x6cb84e9848ce59bbbd66e8b5617ff7a9b3f60f7a'), + ('\x6cbd2a03d5a73e8b94e0291cec6567425a8bb231'), + ('\x6cc14a8826a43caf2c86ddb2e633cc794932f026'), + ('\x6ccd8a77dbc22425737bd1c4923ea920e460632f'), + ('\x6ccd9cfc59c5dc2c92f01b3356a3a235c9c9e1c2'), + ('\x6cd69f9fe65afe94200dcc54e51404d1afd427f0'), + ('\x6ce2e6441fc7b1a299618106c06076be552577dc'), + ('\x6ce408e46bfde68b5d910a335cf817678d1bd99a'), + ('\x6cee3fe54a061c652a2ddf33af8c5605882b7492'), + ('\x6cf05d0a7439613be4796d61841e172b23f001ab'), + ('\x6cf1c3ad5c070db1bbc9429c5bbda8b9824eda69'), + ('\x6cf49080a18f4b5e59616a46f064f49e70324a29'), + ('\x6cf6bad32bfb02056db7af11d26819a9ff48c6c5'), + ('\x6d11086de19c19be6e034ff0b22a4bd19bece907'), + ('\x6d117ff47b29cfe7e73e870d8cb6c106c77ab43a'), + ('\x6d12d779ac1f78a799460a63021c9425f4ed360b'), + ('\x6d15067afdc99775c5b3239df58ded75559cfa7f'), + ('\x6d162b72bcce2b1088b8e2cbf1090fa6a2b72e4b'), + ('\x6d17ce0b01ac69a8e4ea03fdd42199c649561bec'), + ('\x6d1a39b47ee077ce4585c117b9139dfe75c4a0c5'), + ('\x6d1eca0b5dfad65088a9685c18eb34da30e41993'), + ('\x6d2612b91fdc7fba815b2b1075d744dae8ec3427'), + ('\x6d29b828aaea1f77d5dc43380cfa546490880807'), + ('\x6d2b14bda10af1355124954691b4ac55672d5d36'), + ('\x6d2f2c9fdb5c2b68c9f55190214d2b1f0356c05c'), + ('\x6d311860cac3e077b585d2d113236350fc790086'), + ('\x6d33de6c0b05e66a50818b854b6268dedd86c500'), + ('\x6d345d5ff6ecf2c1f72007916eb91e50ab3cfc63'), + ('\x6d38d4daceea40f20cdf5635d6eea84af13d02bd'), + ('\x6d39b538bd2e4b6e0560c33a59c31801e95dbb78'), + ('\x6d3bee01c60c2da276201e6fbf85e86e5b567c79'), + ('\x6d3d6b61e48f77c210667e332a952a186d409dbd'), + ('\x6d41b6bc5666f47499dfb40f0bcebc43e7044719'), + ('\x6d479d65a568e07afdf273fb6c588e0ff27aa68a'), + ('\x6d4e5898e17c36beaf129481edbf54e3982513ae'), + ('\x6d57721bd88465575ac467516f6e03fe78e1f2bb'), + ('\x6d588499269b09ce669dfba12156a3c90597b0ca'), + ('\x6d5ea0257f7e72da138428bb1ae7d3e6990c715e'), + ('\x6d6472596f23be361b81004ad0396b127dee1060'), + ('\x6d6890a375f4bf33179dd14307bfe10c22261d1e'), + ('\x6d6afeb2eeedcbe25a5ed39b7720d158160bb03d'), + ('\x6d76b82ce7b999243603e4e03725e9bb8e637f93'), + ('\x6d76cf81cd316fa76d9c92a49d49b7ee1d879e5c'), + ('\x6d77e525badbdff69a8b610ec834302b0c702462'), + ('\x6d7bcc856934b6bf14ae17b6cb70b1fc3995e266'), + ('\x6d8231230ef7c025f180f0486ca7eaa774c6f518'), + ('\x6d83089e9fc856dbd38e27b5edfb87dec94272d9'), + ('\x6d868143bc557a5b4b819fdb66590893fab89266'), + ('\x6d8b74b859db56af880e6e0b5557c3d197fe1880'), + ('\x6d8ccff2493bbf6ee50dbd0c6e6e6a437d307c6e'), + ('\x6d9a663c36f60e00cfbeee74da09cfad91913977'), + ('\x6d9dd01191207767558059c54c9dfc62f8b1e8ee'), + ('\x6da03b038b5f71a53caba3f84b40cf2ac65b7654'), + ('\x6da19d74d2a8243f1df9d3859538a26e932cbca2'), + ('\x6da89d09829b43732904a9e75fc4171ab60f0154'), + ('\x6da9a6c5ea38ce81d8df5fbfcbb8f370256d3b76'), + ('\x6dab6a87f9731dd79ccdb19fcbee730a66443da6'), + ('\x6dae1b155031f3181c7b49e9db3585d99ee69e52'), + ('\x6daf20a9f599ceb9907510c636ed01e362020baf'), + ('\x6daf5b6670da083708c5e180110d6cf68e5bdb3e'), + ('\x6db334b1643849ca65fe40f683823723af6d33f4'), + ('\x6db7a2124428c425d58af72f5094b4762428828f'), + ('\x6dc070e54c8a79841c8b82f2f650b46628207fd6'), + ('\x6dc16d1e3cb990766213487c98007ebc356a893b'), + ('\x6dc30e507fe8c178e0ca69f914a6398d6cf37940'), + ('\x6dc464eeacdb5cc8b6a60aaae6baf86565908d8b'), + ('\x6dc8b9048955ffa3714283ccd829ce523b84b8db'), + ('\x6dcdd299b6acbe221cf239becb11c82ae710ab92'), + ('\x6dce0cbde16a53a0dff467dedf6bcdb54fc94be3'), + ('\x6dcfda3c601d8e66a1e311e9e2836f474c127d4f'), + ('\x6dd304dddbaf9e8612810740384d119e714c3949'), + ('\x6ddb827828eb2a7200bc6ab4a637ce4631c1ef3a'), + ('\x6de5002d3c494cda8ef7aebc5eee3852a2f6b2ba'), + ('\x6ded73794344363d2976e4382cacc73929c74cb1'), + ('\x6df1bc7bb5be3bc885a7521d9702e1b52d5e702a'), + ('\x6df2f79f8f7e2b833cc10ae8dcac5f46e0700e41'), + ('\x6df4b0d7c3dd2d98ac906709f101754590ac86d9'), + ('\x6df4ecabe09a708b4b4266ced519b3779bb04e38'), + ('\x6df5621696ae134d1dc6197785e9295665522db4'), + ('\x6dfbe0c8b2da66e2994abec3b56948a35690c289'), + ('\x6e024054e42eb7b9c54b15ac63f9a41cc709ec74'), + ('\x6e04ac0ba482df0860b9debfb2601604b13adfb1'), + ('\x6e066b12be8c94ff36a854a1302ed57d4b6b1134'), + ('\x6e09d8aab8a9b71de0906817604d3389986493ae'), + ('\x6e0d0226e136a77dced09230570c476e048aaf45'), + ('\x6e0fc6ad5265ea400e34a080174fdd14eb8bcde0'), + ('\x6e26240adf12d420de95f881a492a8aee98bf84c'), + ('\x6e26de76d46d7e4c28d19acb691cc8a4b41b4287'), + ('\x6e2a05b20055e4fdf8988260e89d07ffa9754639'), + ('\x6e2acc57a11db0f68178ab0f3f868a24e3c00ba1'), + ('\x6e2ca89dc287532c78500ce3ebd76e08dba5d360'), + ('\x6e2e2f03e7ffe9e030c36cafa308b5660b78011f'), + ('\x6e37fc93fa42b97cc4f835f002e4e6d9c36b32f8'), + ('\x6e3adb293c65e976436bc733815700e024e3657e'), + ('\x6e44c3a4c239f35cd594e90b234ca2298eb3d4f0'), + ('\x6e48e88f55e2be63ed935f4b0d8e74f40f015cd4'), + ('\x6e524f89ffab44c8664987db0c2e701a5d5ff9ae'), + ('\x6e5c71623f18751feb01326a5d0e5a6b545a7e3a'), + ('\x6e5ebd7d9456cb84e1036fc6396ef2655d70861c'), + ('\x6e5fa998a3fb5a28a6cb6cd7bd2c21453064da46'), + ('\x6e5fff7d401be4fde42dd60cddb49c1b0f136d7e'), + ('\x6e622a50506512631007ce696dcb973c3f99ef99'), + ('\x6e622ed4a2db4c52c44de80229bbd12773806cca'), + ('\x6e652456c8da99cf38d81fced9d36ba7e7112c98'), + ('\x6e66ae774beab5d9d919690e4c7452ac839808b0'), + ('\x6e672ddb34aae51a30d6e531774c1bcc54cf79af'), + ('\x6e6ca6258401a59262db9bc368adc7d11292b233'), + ('\x6e7177dec0c8b9ffba173b2ee2e23610fde3ef57'), + ('\x6e74c8be3c7700b4bfaf238e2b72d1356678f4de'), + ('\x6e75f7083179fe98fc5fd57e7f2cc2c2ecc75885'), + ('\x6e7960786ef4610b64aed431112ba676f4a10329'), + ('\x6e8565c4bee3a39c3dd4436aa1917e052e96a4d1'), + ('\x6e85f045419423024d9f6c907a976192db697ead'), + ('\x6e8990878026a74b15cae6351da049b6ba1d13e3'), + ('\x6e8ba88fb7f550651839ef4c1ac06629b855aa2d'), + ('\x6e8d9dda503cc22df49459877df3b98685f5f93d'), + ('\x6e9093001dff8954363d08fad01ff2bc1133fa6b'), + ('\x6e9238ca681b0a21f332f5a148a3dcf5b098065a'), + ('\x6e94c0abe7abc1ddc44231d5cefe69e3a8f48933'), + ('\x6e97cdc29ea3c9eeb09b42013edfbe51115cc845'), + ('\x6ea31a2da37d90c1ebbbb9576dd41efdc4fe861d'), + ('\x6ea3e49fe955b978a271d86a863b119855040211'), + ('\x6eb307b681c43af22d1987d079af4d7c34199a1b'), + ('\x6eb4a1156680389014173969df40fa52059eb685'), + ('\x6ebc22c22b54a01e90065d631afc4b2096b1c144'), + ('\x6ec0305895906730a9375b3761e4f7441d0d3fe6'), + ('\x6ec162e77bee1df08391a4a7cfeec215f305eee8'), + ('\x6ec388a3cb47306eb4ea2e4fa32e5c5cd86c10ce'), + ('\x6ec5277f373fe4ebb374962a8528566dece062d9'), + ('\x6ecda8c5c13174a0811f6378ab29ff4884bc5883'), + ('\x6ee50e5b055a48c2337a2c3222cbe87ce307f16d'), + ('\x6ee6cc21b707e73bf70119459bcf2f286be47548'), + ('\x6eeb47b155f1763edf104ba36e6d147c21bdc7cb'), + ('\x6eebc68df030da27e450ae4b18ed60c32f51efd4'), + ('\x6ef54028dd7a5f4183f4704c61371f7beec07378'), + ('\x6ef6ff0ae28248404d8868b5d52c36e35eaba955'), + ('\x6efaa3ab02acec543aa867c0f32576a911c8bf0b'), + ('\x6f08a3d4ddd190fd4b0bf6b2627ffb46f0696989'), + ('\x6f184e250ee63b79966b6854065a792068ef3d35'), + ('\x6f1d62d6ee77733a267ac96c7f7303a53979c4c1'), + ('\x6f236baa0a168bb0c8f252ea7938f33ffcea437a'), + ('\x6f26a66e7945000dc560f8f67ac79f446677dca3'), + ('\x6f272d6b4d61deebde7419f2fb4139e51b8744a0'), + ('\x6f3489530cbd990b78bb679a427bde8ccfe8de08'), + ('\x6f37b117dbfa617b5f940b86bb4a59f6f993132b'), + ('\x6f3968d360251906c06b52abb1aac2e61d5aeb62'), + ('\x6f3f5ad98cf2bbc6b040335511a40597d6c86c05'), + ('\x6f4ac1fbd6a010dc9783e96c208a66bc2cce83d0'), + ('\x6f4e41e3f827e6c95333747416af19bc9b4b3ecd'), + ('\x6f4f86a573d94fa098c8248ab5a44503335d18b3'), + ('\x6f575351e4ab5954d357b909445ddc664d31d38c'), + ('\x6f5c084ebec417f4e58129087edd536cd6c895b4'), + ('\x6f60085589b7a2810170c82fd613c8a456e142d4'), + ('\x6f712bc0db6a6182679b2745065e3f7d53de1263'), + ('\x6f9357c57638c724ba9483e78f26fc25f8a1f84a'), + ('\x6f938be5ddc740b8e21537e3a4603566531c9325'), + ('\x6f9bbd9942892962612a2432b0ec6f1246411d0a'), + ('\x6f9cbce6a1ecdc9ad44bd52dbe3668cd9b7db847'), + ('\x6fa1d63caf45572c21e76c803c14d7df8b796068'), + ('\x6fa36d3c1cf0b2eb36de105986ff2aeaaaf097dc'), + ('\x6faef2aa253fde303001ecfc2ca34aaeadfc4685'), + ('\x6fb07b6e5a5adfba92115dd3464be7b63306a8ca'), + ('\x6fb6bf233767f459129bca2e4bd3a3fb6b255ffb'), + ('\x6fba4c7c890be254d10f83fda32f0c3ae2ec855e'), + ('\x6fc2e0512718c0be23bb9cf376c36b952119e253'), + ('\x6fc5574d2cd38270bdf306c04c46d6436df517be'), + ('\x6fc9976313cefce545fc0aed28ae8ba9acefbad6'), + ('\x6fd0372393b3098cc64726b5152cd3836b87f238'), + ('\x6fd0952edf50ac62446b357eede2b0f7ab270f20'), + ('\x6fd0bf0b198e501f299385146ac1debfe4311ab6'), + ('\x6fd910995572b73a8c9a7912d24e410ae305eed0'), + ('\x6fdd107f9a6b47eaded5f4b41e38318f22c2f7cf'), + ('\x6fdf46ab8e8a408d2b6ed611b011103a0b0267a4'), + ('\x6fe0071418bde06af694f4b272e85fcbfedda008'), + ('\x6fe42fb77e25bde42993b6226d744006c77eb603'), + ('\x6fe5003815e4bd20772648d0deb1c50360b2b04c'), + ('\x6fe8f81284af1b2bcd6fc7ea5c211d4275839aa6'), + ('\x6fed77d7b4322e70351a91ca7858241952b694b7'), + ('\x6ff21f63a1f7dbf4bdfaf3c3a9e9bbb558c3238c'), + ('\x70190d7c655655ca47096e46611a99965d0563c7'), + ('\x701bd6543f4490f49dc9ecb68fdef88d60d2c8c0'), + ('\x702a12c1183051ac1bb4a23c2959c4e67f2ff130'), + ('\x702c07f11f35005831c61bc7e302ed6ce816bcee'), + ('\x702f0ee348a35cbc06ccf7d6aeff59b726eba955'), + ('\x7030b0f8b4358c295ba4ed575f90a23517b40d8b'), + ('\x7034f27442652a6195f674235042e68189fd736b'), + ('\x703795363ab8f987c0343e6a953677d45f9611cc'), + ('\x703e1bc200abe77ba93031ded02bc5b61565dbc5'), + ('\x703f24b196e7347135de7966b33657fc4d2431d1'), + ('\x70404faf2989a653e52a2b51755fb04c18e8ba63'), + ('\x704366607b17d0062938e0d316339bb0ef88cec4'), + ('\x704671551ebae8c500d1a8afdd0d174738cb626b'), + ('\x7047b76a36551c3193e60cf0c4cd114c57c805ca'), + ('\x704e75866803ea5aa7d9090402842d097418a49c'), + ('\x704f5469785e71b61ce679c66efb487a4006fb1b'), + ('\x704f7b1bf280c7fb00c47ef3807f70d935f17e4c'), + ('\x70503e0ca36c2e83a40d5b4bc7b55cc0669b6ad8'), + ('\x7055da5524b8868c3bc493b90fec97118638aef4'), + ('\x705654cb7b1d4dc148b2137551ed1a7281ea7eab'), + ('\x7062aad6799c7459d0ae8c112c81ca65a5b6602e'), + ('\x706a0a3b10bcf6cda31a3e5041db636ed13c2b07'), + ('\x706bffcec5a4128b404366745dd84f6e5ca3b909'), + ('\x706f4262d64a386576985780670a6dced6da2dd7'), + ('\x7071c0ad92cdf597a8aa21a2684d062a1a6aaf11'), + ('\x70762c09d9caeb922bbdbeaf9844c9a5f59e9666'), + ('\x7076ffaa75f65be8616baf8979d44cae21edfba2'), + ('\x707aba11b6a43217aa64e89de7caf3bfef1f1908'), + ('\x70844c297ea68425943d7a5375f716d0b41ec20e'), + ('\x7086df22a70a159f3ecfb6d38eab67cc862e1105'), + ('\x708e73e3090c1077f262af713c364e1f373270ca'), + ('\x708ead09720f9c4b8e060e4e1fe9bec6b3f59667'), + ('\x708f76333c689ba475b78963c8994203129e7bed'), + ('\x709881df55519655c79e0ba9870c354e85a4189e'), + ('\x709acfc36bd67a86ee5c971ba232b3424734fbba'), + ('\x70a2b618752d2b507d43a8c1bbdc9c2e59e408b4'), + ('\x70a8ea7e5a7af6001f0ed77dc73a699bf0b2a93b'), + ('\x70b0a5712ce5f5b8cb66c71cae61fb34d3763508'), + ('\x70b2c948e912936f1d8846530fb9d8824bb5a151'), + ('\x70b6ede72bfa63fc0ecf0f2143228996ab83f146'), + ('\x70bb398a6c496002415fda22607e033eaa140627'), + ('\x70c2a183215dd26dbc885906bfc29764ef2237af'), + ('\x70cd6683f129e5d70027629f85c019e0b0cf0b49'), + ('\x70d507cc6eab3651a167d5bf9f6c80dad2169fbe'), + ('\x70d7e982b65ed79142913c3f655fc3a22167eecd'), + ('\x70dc302ee2e4170b0dc42e4e0830d2562def5082'), + ('\x70de787176f1156c30a942c58b2726cf457cc441'), + ('\x70ea664909ad345f920ea9ab538aad036a36735c'), + ('\x70ef2c69064d56af1ddff0457de760d65f30ac4d'), + ('\x70fd87fd399848dddc867208c2dbbc5292a23162'), + ('\x710579009ff2406b44cb2ed3e2056c2e9df8204d'), + ('\x710ced13939917f6708d41e29f14d50869d4ad3e'), + ('\x710db7a6bfc3879092c6c73adb606c33d02583cd'), + ('\x710ff7a8e493d89fde0b812d36a67e13535f7ad2'), + ('\x711606a8d1f2de5ff35acf826b169faf91875c36'), + ('\x71199fd17ba9d3e8ddb744e8f50c0c6688cbac7b'), + ('\x711abdc0945a6b81b89f5cf5b8fb5051748f3551'), + ('\x7125c5a9055ed2ab65d1cae117abda4077011d52'), + ('\x71385283b442e750d7cfa22c595854a1e66f21ec'), + ('\x713ac34c085beb78ecf4597c383b47aa1c13fa11'), + ('\x7146684d66d531b60332818145f5bb98d756d910'), + ('\x714dcd54cd2609a95ef7c71f12c86655871e3c23'), + ('\x71510f30dc46d0455f4a01bd516fd4d58b7f7aff'), + ('\x715ada74d51a7ece00180c42f5fef49b19431ba3'), + ('\x715b4800602843682b071ed524c98b4dcc920eca'), + ('\x715be03b5cee03530cc370cd2f72f89555d393d0'), + ('\x716a19079eb26f02de81f267ed78317a6504cc6c'), + ('\x716b108eab11bbe5281323301043958a45c24dfb'), + ('\x716bbc2cfbc1f0377f9c51a76e1c0a532391f612'), + ('\x716cd85352f9ac2bef899edbd1bca83ecb997216'), + ('\x716dbd5824bb0d27cafe7c39c17e339c0236e6d3'), + ('\x7170f0d80c2f5f46c82e1071cf41fcf5f0b2f6c1'), + ('\x717a6b483ce9e880bbddfed2368f4e9d7531c832'), + ('\x717bd5db63cc08880dc11be275f7c9991db7c6d8'), + ('\x717e2c6a3979634067ac2d4db823451c4f1f7dbc'), + ('\x717f6e02e5dee52a3092a441c64e9f0163a31a98'), + ('\x717fdf0cc417964d92e1f94d96ed7e675efd1a45'), + ('\x71823ff5c47ddf56552a7b3e79c1369c31da1300'), + ('\x7184c0711f75948cace3ef4362974f8173c5cd20'), + ('\x71895994193bd25e2c25ccbe79c7a9333d312c4a'), + ('\x71895beb101a45f5b436d3babb4b38ceb3b6674f'), + ('\x719398639a4cb2c18f9d795f6ac0de59399d837b'), + ('\x7195f52ec6c9d7f0d1680b95ad11b94601e24090'), + ('\x7197cf1df8b1115497382492ad4653d082e2efdd'), + ('\x71a01afcc9e846262b5c1419c9a15abc16577a47'), + ('\x71a1540b66ba61eef59e72a6ae30197e8657b0f5'), + ('\x71a346294515e812b06662ba54a7df0b07ce5389'), + ('\x71a520e1ce0a14db6cc326111189f67eb0243601'), + ('\x71af0ee1a30e48eeb728dc584277ac473f9e357c'), + ('\x71af6e073e43ab7edf1bd875bf05211d7e578609'), + ('\x71b0b58c294cf88535038d7ec601d85db45880d3'), + ('\x71b1624d376e4aa0a04c860db718f92fe6380804'), + ('\x71b2dd4c5f22321fb284462864698215ddc52ee1'), + ('\x71b39089781619fb9b9ad2d8aab8c2e4fc3ef6d3'), + ('\x71b49c0612ea22da7cb891d69eb4a2460a0eaf32'), + ('\x71bec6addd25822ba8c1d9f287271ad9c59c75fb'), + ('\x71bff613e297c76284f5659469cfc7b94eb2a270'), + ('\x71c6db5335e9e7f744146d866b2c058fddbe1c75'), + ('\x71c72811854aba1d0d3742f29b9bef03ec43515b'), + ('\x71d01ed977a7dc069877a772144489fc2435a854'), + ('\x71d5006593f89954e4e7809af24f9e4b0c680b9a'), + ('\x71d8fbcefaa96bdf76b76f063dbcd62a38df422a'), + ('\x71d907819590d16b55d2b08efc5ebfe29a943fed'), + ('\x71df3299809c95d33dbc45e4523fc4f0b49cc745'), + ('\x71e23b3a14786f0a42fe3885f85e67990b1a18a8'), + ('\x71e33830223c4c05c61002462e13df02bb30ae02'), + ('\x71ef99c4fbe4b07b1881b5d11010a8e6306409e5'), + ('\x71f3f1beeb32b20f0ecb0cbd60fa516deb15e6e6'), + ('\x71f44c2b9b14103cc0e833c5303897746087e8d4'), + ('\x71fbdc85d6c2f641d0206ce8b2a0f9802cefdd78'), + ('\x71ffe19940f1436a8914b8590b1a83df571f97cf'), + ('\x72048d43c0e57c9545a4787368c470293a55f61e'), + ('\x7204eafcdcc164af361227e4c81151c73b77ac3f'), + ('\x720ab1643555232c9cc2b5cb719ff8ca96ac146a'), + ('\x720b2ad7aa04d089f16c5d7a1f6d31707e702ce9'), + ('\x720dcb93f135eb2821780ec0c6cea94e36749150'), + ('\x72120fcd7db0870791775d29cc3d45eabc2988cd'), + ('\x72178592941d4fc68d5bd9a85738560413ecb8ba'), + ('\x721b49dfcdefbe98fefdeb4acde5619fb51f44c9'), + ('\x721b91b3bd1c89785f130fdbe0b0fb18087fde3b'), + ('\x72227ba6f1673f13d3228175704726687121940b'), + ('\x72289a1b1637af8cf550be19a43483df30d778b8'), + ('\x722a6f1b4be142df8a5dc4481b087a9ebfefc0b1'), + ('\x72320eee41e0a1c00a300fad2a31ab24cf9c0951'), + ('\x72347f5c88401112488b8abf9b65f8c205cd33db'), + ('\x7235526ac7753eca51cc43184814f47aa2d8f286'), + ('\x7237e772f729ec6cf9bba86c1d00cbe695a38fb5'), + ('\x723b3d29cd836d8dd5a6ac163273f8f1eb8bee19'), + ('\x72405b568eaf37015e41d9a63fd62ea680a2365e'), + ('\x724e4ab7a99dbd0fec0f4f3191722e29720ddc17'), + ('\x725db2bc92c46428a3654ace5f0145ea429edbbf'), + ('\x725edf3eb934de2075ab9286a0bb57aa9d1f3435'), + ('\x7260bd65e0dd2df5d0c398d579c126788603e5fd'), + ('\x72613a6f65261ae7ab3645fe68d40d711648c6e2'), + ('\x7262ef5f227fafee803b274ffe4a5762176e8696'), + ('\x7266474bd309bb25eb8c2658aa7a7925b274cae9'), + ('\x72674cdcac4b07362e8c75a03aea4cd120a45786'), + ('\x7267d6fd50c363ee23116897fcd08195262f55a0'), + ('\x726f4c9cacecc5b6a967aedda0bf9f1e5b121494'), + ('\x7271e7896e242fd62caea7280a5f834935e35fdb'), + ('\x727414367705cee5108a41d71688759c443e1e92'), + ('\x7276c25e374f1af0b4bea805473c2509395667fa'), + ('\x727dfb40cdf9f19d261e86b6cfcf5938cdd59ada'), + ('\x72806ff0e499035727e4ffd0d8dd02b5342f2931'), + ('\x7285c5bb83e7663e59b75f169f6f6bd1cd0233c2'), + ('\x72860e865897ab3aad1cf0c1bb7253222c3239d8'), + ('\x72869ab9fe9c33b32afd751ab79f39e8b3f52f4f'), + ('\x72954125a77cd7312838f0c3914bbf2250c8f6e4'), + ('\x729993b87a9ecb29371b124ae445e8030dca3c33'), + ('\x729dd7b3c40fee4177b395756d1cf01ef60a2244'), + ('\x72a1d14a0fa7aeb3abf9e597baa6190f2c274690'), + ('\x72a57e3f79e1864bd336964f28bef82536bd5651'), + ('\x72a6d36239fc69d79d1374a2d1fe84220f24ad30'), + ('\x72abe946e54b2c8e2d0b1a9fd35e4b953bb1260f'), + ('\x72b1fe51b44808a3e8578784664d298c9d21eb2c'), + ('\x72b5d9e3228bb3a56fa3c87c157d69f388bbfb8a'), + ('\x72bd08f7647fcba66bed435d609e15a3b329d9f6'), + ('\x72c53de12489defd1c6e6a801f552f8885fa5c26'), + ('\x72c82020dde743b9fe3f92338f82b758ac430f92'), + ('\x72d0f2b2fcca084cc0a2fbee9e7bc2e4dc822941'), + ('\x72d190dbbf53cb745a87f17bbeef0909054594fa'), + ('\x72d2c3f0372cfe5060c41cd722a1af110d03ab36'), + ('\x72d493b13f0c6cba97a0a171bd578637b33c752c'), + ('\x72d4a1a03cbb473215fd175010745fcae3d51cce'), + ('\x72d6edb49b2443c6eadba1642cb47786c5721677'), + ('\x72da2d33297fb37439cc2e3b38110cda49f65bcc'), + ('\x72e256f18cd24175cd32dd5aa10e6cde030c31db'), + ('\x72e5c69a351047626b2eefc889c61e4632118dde'), + ('\x72e6b46592371787a4850a7c4cf80bdcf328acb2'), + ('\x72e9fe3713f968d50f183771f722e3d85095cb54'), + ('\x72ea9b21ac10a6522c12ef34918ba35504519368'), + ('\x72ed3af87bca7c2130a771c97582b46f91b4b6b0'), + ('\x72edc5dea57ad3eaf3bd745c6cbcd487cc43cd88'), + ('\x72ef0dfd9191a17513789d453e982fdeaa50f55e'), + ('\x72eff2080ab8e0f221a11b1cf7a47ebd1d0967da'), + ('\x72f4491e6f371aa273668d055a25280fa0b9f5d5'), + ('\x72ffa479629ab7274c0978b562e8c23b771f0ec5'), + ('\x73019c235dcf49467a2f2049300d2c51ae459270'), + ('\x7306773e78e4fcfa15f1001b7cfc4aca612f940a'), + ('\x7309c6b12727eb1cc689ba24be63e7860cecff8d'), + ('\x730e8c38ed8d400c35a4c90bce2b40fdd7379b29'), + ('\x730eeba36d77287a33b8444a8958cd8b84d7ed7d'), + ('\x731ba4226b6e8f488b80af73165e85710f21b278'), + ('\x731d7690511d046dace661477ce3d0e6d9c3c548'), + ('\x731d85a88c2fb2bd02ad20d3801c327731e51ad2'), + ('\x731e0962ff3392dddbe16161bfe236540b6908ca'), + ('\x7320ad1cbc1d1cbfd0d37ca08d7c2fc737d2df34'), + ('\x7328f0703138ebbbc1f918d6c9e03ec67868ea97'), + ('\x732b477ecccfa3f426f35ec21ec3804c0fb9fff6'), + ('\x732c3ec02e9d75470a748e42d7b32d7490029e5a'), + ('\x732f0610a224a7b4f06b9b98f1841c65ada14321'), + ('\x7333fcfbbf335e2642976d834c415abc21d8ac39'), + ('\x7335226ec26e684fbee2a3e33392bb575618f2aa'), + ('\x73358b42f1887adbac1caab894f0e145cb6edb53'), + ('\x73437c916f5c140585a2ea3a9bb3c65e994bba10'), + ('\x7348ff4b731a927491293f18e4ab8522ec62f608'), + ('\x7349af6aaebd8011df5c8bbce868374a22d9a779'), + ('\x734e97d3c41278f6aa81d51a6f987bbc4fc3b6f5'), + ('\x7352f8227769a6cf37dae00db3103f70b4b8a04b'), + ('\x7355e216e2a4a929ea5eee5caa185685731f62e1'), + ('\x735b597076a7da245bb7ab3db4a2e08a99ccb5a8'), + ('\x735f485e359030bef6599501487d5ebd352cb960'), + ('\x7363f91e3105973d2b73245c63bc48fe7a225857'), + ('\x736694540db6d710bb969caaf64a4f750a86fd34'), + ('\x737bfd55328d5c9492985bbe2b79b53b48692d81'), + ('\x737c89ef0bc55bbb28e15f8bf5c8730a28a7b27f'), + ('\x737c921bef23b218da261e77d0bed44f871d06d9'), + ('\x737fb474546527fea8db9f056fee49a790ec49e8'), + ('\x738a2834271341fbfab03905e0f1bbac06f3874d'), + ('\x739068ce4384c0645521887d6596cf3d3d9e7cec'), + ('\x739f5dbd3703fe2a661564027fddcdad96390afc'), + ('\x73af21ae0249a1c7db68882f593bf021144b25a7'), + ('\x73b5f59f7d5aa1368a0948629c2937a48f8d5682'), + ('\x73bef64ae08760410ccc44ad4ccc50c5b3f7f424'), + ('\x73c056262ba76fc60ba2fc9aa3f670f281bf84fc'), + ('\x73c398f68221d05c8ae0750a1ed9f327e4f13a7b'), + ('\x73c47565a0791fdf06baad81fce262ba5faec77f'), + ('\x73c863e2d6b7e17bed73cc9361538e7ed1ca1a12'), + ('\x73ca06345ad6a8d49288df5f45651c240a955f11'), + ('\x73caa3b3ccf7e29dec1764050aad6132c75c0a21'), + ('\x73d4b35ec47309786bb938a3cccaa23a0702c8f3'), + ('\x73da54589e98501e9fd7f9da2b30ae7bfed2ef5f'), + ('\x73dc5d67072f419fa808b16025a3109770a65e38'), + ('\x73df10be9b9c4cf53a6e313d5925c7554ead74bd'), + ('\x73e118a5c4ee6c97c8aa332ffd8e8928ddc81dd7'), + ('\x73e1997394f9dfa0796982ec18c204e3268d53be'), + ('\x73e363058b8858870b2921cc69a1e4ef7c72e7a3'), + ('\x73ee2926d61e1719f8ad625fc53182f57393d2ee'), + ('\x73fc6cb52d7cbae0809bdc387fc19a897c768e80'), + ('\x74045c4cd326bb770a92757bdc36a77ca44102e4'), + ('\x740a1bc245b28bf0088c3a62e7a546816ed2fa38'), + ('\x740cb887cf5827acc969b10819e69293737eb1f2'), + ('\x74106b357914b2879726e97a08f926db24e023f9'), + ('\x7411d703e47a7e2a6d3fd449122953540bb38c57'), + ('\x741217a7c8576a3792d2ed41c19cec8534964227'), + ('\x7412290078594ee533d1d7bb0badfd485d60bace'), + ('\x7417399462bf2b3827351cd88d3ba08e8df2cb19'), + ('\x741a86bde62a945b17aadab3e68dd9afb99c73f3'), + ('\x741db56a44e1aaeaca95a6bb2e149eaf2f18194d'), + ('\x741e9ec966555f62c0adb2a85c318a826eef0139'), + ('\x7428631ce168ba0c10daf15a1c137e4f8dc49030'), + ('\x742c28d08b345334bc8bd54d5b24b96d352ab90b'), + ('\x7432912f16e51a6e443e43a152d64a512fc50263'), + ('\x7433940c4b785f339745c3ecd060d0354ce01794'), + ('\x74343694e2b2114272f38b1124813b972cb592e5'), + ('\x7437358547ee43f969fc0cb8e383e666bb0bb96b'), + ('\x7437f8b924be19aa87efa85731b1e191b5b35447'), + ('\x7439a15aa86ce1d9ec343f8e0b5ebf1e4cf09346'), + ('\x743f0562a42881d8f935f675d236baeada73e89e'), + ('\x744597d027ade8bcc6e6d090c6d3bacb8dd078c8'), + ('\x744ff0ca7d86fc3ebde835416783f72715331677'), + ('\x7454ab2739504e18251e1054e701fb48a006d89a'), + ('\x7454ccf559a3ee4f19ea19ae8bc71e02e1dab5d1'), + ('\x7455a0890d6b810f60642266cf8b9a06096dac3c'), + ('\x7457002a23f0e824bb6f9cae3a951c2c3342bc38'), + ('\x7462e980d7f30dc66f85c675ebf2de372f670424'), + ('\x7468031120b80e0ede476807cb530b1bcb8cfb28'), + ('\x7487c19cde4b121035f600c68991c73d136e70b6'), + ('\x748c38e0002f805acd54cb6677be879bcc4a998a'), + ('\x748e2feae4b0de89e1ce4d31efba7ff0e6c7cd9c'), + ('\x74a0ce4dbcf958cf44b32218781b71fec5d6b826'), + ('\x74a1a2c67c8b1eb12446db19ff17aca8830816bd'), + ('\x74a85898f567f667b4796adc6203ea175df82a59'), + ('\x74aa61014fdc21f6ffba9f3ad1d7cf0eee9aa29f'), + ('\x74ab95c08ba617a14df2707aee19db4248a95877'), + ('\x74afb2cad567e7ccb088ac06872aa9f74e33d434'), + ('\x74b777b150b6d3d60b9f62bffb471dadcbb1650a'), + ('\x74ba03f7e33528cb5dab67a79681720ebf3aeaa9'), + ('\x74be28de7ffafcd719bcc205df1c2214b989e2c1'), + ('\x74c024f56196cfefbc6af31da43afe1e66c5594a'), + ('\x74c2134bda730f35008358fea52c8efe182b0fa2'), + ('\x74c2e1259cfdd6ff546866c4764ee476ba966998'), + ('\x74d256fd269c55dd8df31aee07b29a7cd9b78177'), + ('\x74da22e76b5dad96a1a18f4c509883005f9c4d14'), + ('\x74e0462c21a574e907b9b5a8e2c6f74f68424cb1'), + ('\x74e6c2797e7372987ae162cd007355106074da6e'), + ('\x74ed0b15c121e4116effd35fa76fdaccd4e35dae'), + ('\x74ef1789a2c41312752031b018fc3d4bb5a65641'), + ('\x74ef6f8d0328eaf81d9b1226b6327a52b1285029'), + ('\x74f289135bfe4da94967977a9ffacd1f3333c34a'), + ('\x74f3a84f0f864f2fea67898c0ab67aecd8b7343e'), + ('\x74f55d9373b3930400e3ae24ebe07c604ad7bf95'), + ('\x74fb64ce9ddf2797ba82dcf4a13e272fc002752a'), + ('\x74fcce1a111bfa75f064a8f4a3e70490f48b5454'), + ('\x74ffb7b57b048977244fdca0a5d55eac1d828cd8'), + ('\x7500cd99f1486ee2e1da7f7953ac0adf28f2c726'), + ('\x750106477f299de05a1ab4ca84cef588fb7d318c'), + ('\x750294cd91c899cfc2a287270e03ec33dca9c355'), + ('\x750664c64d8905710ad1e0adb8ec9ab23f2f6eb9'), + ('\x7507b0942ad6e64b5f506f8d53efe1d4801d4d7d'), + ('\x750890014a0331f761cb12dfd09eec326d56e975'), + ('\x7508b130e0917a166efa9c3bd706cbbcd8144259'), + ('\x750b45d4c3cdc9efb6e7aadc0c4135c88f930231'), + ('\x752238a579165bde306bb5493ce88c6180845218'), + ('\x75256f2a7529804c208a95ca42ebbcc301c0f5dd'), + ('\x7528c02be0bc0ecd05bfdb704a32f00837e0bf53'), + ('\x7530f8c12894dd38288c3199d624ace625b7f2b8'), + ('\x753200ec4ababfb43c5e85fae4819d0102b49417'), + ('\x7538ca3b406a754c812007a9c20b0249fd589f44'), + ('\x753a404bb8c45246fa682e0de98673acfbbf6466'), + ('\x7543c45f2c55512a98e489df69963aa53af68c5a'), + ('\x7544de9cc8d102e1597dac2fb71951c2019a08cd'), + ('\x7548a271d967eb8a1efdf0ffc40b2a69927adb1e'), + ('\x754aff57d146534eedf7bb18ca239b123c79a112'), + ('\x754c662a5918632056cbeb4f0e946ba608c08f01'), + ('\x754c68fa3dd5fe04c1547379977e278de9762cd1'), + ('\x754dca7325de222c8dbdd1f98ae2c1401f882a42'), + ('\x7550ad58d2103d2f86e93abf4c982a022046501c'), + ('\x7551c38d58bdaa08cae4b4067332d528211ecaf6'), + ('\x755406f3b6d4233785304ed5434e62f60ad1478a'), + ('\x755fe03df93e9d5490668310629642685c43de35'), + ('\x75640ae85f639433d18c164afa2444e01480ef7e'), + ('\x756ddb4f9f4b24c3b00eeea92ff3e488d71036e9'), + ('\x756e32ed724c9060432519d5883b273482f205d3'), + ('\x75710161590d3d979566de48dac03b34200ad0f9'), + ('\x75754ec28f11e725a37e58f61407b5f0981a1b85'), + ('\x757762b871c2fd9f5f251e279f83976d1f8243f5'), + ('\x757c2a628dd03b1cbe4b3ef07c153897a702b57a'), + ('\x757de679db86c59ed057868872c8d1636705f1f4'), + ('\x75814f3d01c5a0618fba86782096e70520dfc941'), + ('\x75864fa40c5747501fd8bbf5c9e24b327595dcbc'), + ('\x75926ffc2bc168f37bf9ac005d27e081b6ac190c'), + ('\x75941fe5e4e8f972edbbf62a5abf51cabd4a7d1e'), + ('\x759860cadd101dd38ebe4b4d713ba233bb44914e'), + ('\x759f7e62d295d56f972176560828ab230efe6696'), + ('\x75a00582f9effa20aa0367f30413cb8818d990fb'), + ('\x75b4923483151a7dae2f60b380826bc6d20e87e7'), + ('\x75b57624147e942d065dda73641368ad02e1f7c4'), + ('\x75bcc6fdbb5f92f5a10b330e797b6d6bee7d355a'), + ('\x75bebdb5948f61cec5acb9196f78e3fad7e42686'), + ('\x75c00e4452b2a34c4e5f2ca81a04944c57b7a913'), + ('\x75c2afcc72d5631fa46e2e101f8b34725b9870e7'), + ('\x75c7574beb9588eacc39102b0a99a42ca00ce9c3'), + ('\x75c7d04516ff39cdce337a7f0420b3911852a328'), + ('\x75d3440e776f1a81345cd428945ed5ba3e43fe25'), + ('\x75d9af443edbace80e1f5fe2004ffeb19207b317'), + ('\x75dacf63c0068697cde5daa225fe0ece97bee18f'), + ('\x75dc8a8228b8be1ba54649f47c6f9b3187ce561d'), + ('\x75e17ac21dde9c0578fb2930255f30c45bf102b2'), + ('\x75fae5ee8ba211118718d280f0bcece91ca0e4a3'), + ('\x760599cae59cc7081a5b8fa45a45cce8540db8a6'), + ('\x7606b432449e96a9f45822193d9b5e9e0c5e8c97'), + ('\x7609665fcc698b2a3cb53b730d8da7368619be2a'), + ('\x760a844be864b15df656c583cc7d5c466c0a07f9'), + ('\x760fac5c90f75c40a5e1dcc912fa4c84535faedb'), + ('\x7614de811c48683db7a23d5844e7b66c6eea388a'), + ('\x7615794ab6e183d3d79ffe0300d40bf787730558'), + ('\x7617e142690b8fadd2a307d1b580e870154bb60d'), + ('\x761d0cc1e594d4c06505eee15f53ee267f912726'), + ('\x761ebbf6e79471ced260f36acaec3e825b7e8b98'), + ('\x761fa18c2b8c1ea12049c6c4746da1b69a27cc4d'), + ('\x7620263c65484734cd6dbcfdb9331e2abdc67642'), + ('\x7628b28ed878bc80c6b9e7449802d6a109857f54'), + ('\x763501945ebfe708335b2923bb67f41ca93807cd'), + ('\x7636e452960e216024615ba27c37f9ee7b391e9b'), + ('\x763f2120eba7a74882c2375f40cb4d26d25d422e'), + ('\x76453bcea8223229fb8b5d2c0a06e45ceae4cbaf'), + ('\x7646545c4f34c294a66132eba3c58e0c7259e648'), + ('\x76467d1487aab8b8c8137f167213c92fa8c1f946'), + ('\x7648e59b60dc8b0073410830253ad291a5f53237'), + ('\x764965d4acb8ebfe69a1bdbdbe9b92a20f6e887d'), + ('\x764b0df32c45cad57b234ceb5f2b745c1ad90160'), + ('\x7652bcc725712f5f4209429239671338b05ce9e2'), + ('\x7652c72ed082c0e6f8650aef74a7a896110cc2ef'), + ('\x7659ec3a0ce901af3e2c0ce7013e8477ce4b669b'), + ('\x765ed308041ecd390e7dd97e04ca12cc0a91a317'), + ('\x76647f538ab9a08f30aff698ddb77f0f72be409b'), + ('\x7664fceaafb19d22a2ce35bfb19eee2ffe40a1e6'), + ('\x7666afffcf906dae2afc343b90b60c70f6c60c21'), + ('\x766c4bfc869003c2ec62487921c27c6d41149586'), + ('\x766e15c6fb1f6d82edd5de5fa7c3c9345a0ffdb1'), + ('\x767782eb12bc174009c7dcc41f98a78b5f0132a4'), + ('\x7678b70c2788bf31261d2bf55daef6cf9020e57b'), + ('\x76798e09d8c36b05a22086ffcac3436f47ae4f13'), + ('\x767f51890d5d164a135fbe48af2d61ed2e7441bb'), + ('\x76825b81701f0c162c335fd11dc1b807559bc267'), + ('\x768959c2f36b38c5e1bc7d7d5c9fa3a963247dca'), + ('\x7693fce352f5a9a8c2959c680292281c3bd4d01c'), + ('\x7696d94a5bba8f60475f5443b059f8ad43939824'), + ('\x7697d648bf91005df8a5ac477e1eb0d6a6df39a7'), + ('\x76a96556e667326eebb745a5f97756c039c7a472'), + ('\x76a977d82744ad732410f7b164b7b0ed1b58914f'), + ('\x76aa6d3fe052afbb2d5fd8c508add13f5e31f84b'), + ('\x76abcdb191f0bab3515a7780f0b8fe8fc7c0c53e'), + ('\x76abde3873c2258e8769de7b267b1ef6976eb6bb'), + ('\x76bf5f9e3e482526f0d38ec40e596ff04ae9b8ba'), + ('\x76c5723fce23d620e766a229ede0896b45594a23'), + ('\x76ce0d3ddff37a296e0b3ac7c517102677f4eaa3'), + ('\x76ce3930c0758dc16559b07a87e4b41698960199'), + ('\x76d0b390654a936d9dbdf6b4b887529e11cba081'), + ('\x76e0b40560e6af204aca5d815be118468535572c'), + ('\x76e5910f88ffb9fa688918d58d8dc3b2688b4ae9'), + ('\x76ee15ed0edf8c7bbd683945da41ee8a66ef3927'), + ('\x76f4ffb79568442db9c2c45e962e6d7d2cb5c915'), + ('\x76f9ad5133e569a0057b7766af737829c026bac1'), + ('\x76fee70a6c70a677778ed6f3ca002505bfb2849d'), + ('\x76ff1f1759b00b000a07d77c98415f51f72d04dc'), + ('\x771317ec79777bd70d5870c8c52a4f1f3d6eb01f'), + ('\x771ecd84050512a30da0a6595201e8f746e54b0c'), + ('\x7721f445eaeeee2cbd712026fbf897b89ccac302'), + ('\x7733f321137662e5cbb80c574d0b40fda22379e7'), + ('\x773f9c8e3607f2d9018ef2fc879fd1f566c9e208'), + ('\x7750655494d7bff8f31ca5c25a4aeed3ff8bc96d'), + ('\x77513dd8b81fe812afb7ab50481eff757579fa6a'), + ('\x77534f392213a42974335c504cc65db9a7a50c61'), + ('\x7753ec8996f78adc1c609919cd3273f6d4a1d0da'), + ('\x7754b03f7fe86fb9b2d2d21e65d444e9f7e18975'), + ('\x7754c75bbbc1db5d8f5e472f6bccba954cbef6ea'), + ('\x7754eb064368ebd2d2567524725256141965566b'), + ('\x775c7525d395f402bbcd9673f8b0c2a5769d88e3'), + ('\x77695ec977ecf08b9438b2aac90a334310186d7b'), + ('\x7770204a83c85cd53937752ef2f6ec42d7a10047'), + ('\x7773f299b2b97576db9403b584181b170071afac'), + ('\x7776290db926ff2f693ff5090d5c209393095428'), + ('\x777915cf42d8f23b9661e3c50cb35171dc8018e8'), + ('\x777a9e9ac53315f3cb1ed50ec3562a3a898ac35b'), + ('\x777ca57b97c202e2ba5cc6fbacd4bbc64e07868d'), + ('\x7782e5cd54fd4541483b4ae9999795d1f5153c6b'), + ('\x77889ecd05d6a7da87282d28fdeed0ea672bad9d'), + ('\x778b0f69132054ff48ec7206778a6b190eada5aa'), + ('\x7792514a5cc64d1f2285c2e0d98f5d670bc8ba64'), + ('\x7797f077a163dc689f57f5b21ad207e41803c49e'), + ('\x77981fa640a56c67daa931a1cdece7abcf47c59b'), + ('\x779c3ce5c7d541db5c17468855c821726b5a631a'), + ('\x77a454aa35dd31e2d30c9dbee479c211e64d7f35'), + ('\x77a5dc56f7f3383ef07edfa346e21b70e9f70fe2'), + ('\x77aa9910a66785a973f8fa33a188ad672e27a555'), + ('\x77ac5dfc8dc410aa25f7f15f0c24903043284c49'), + ('\x77aeab67996f4801e05d20a12577f8ba12f61b7b'), + ('\x77b00a938d799deb4f3c01c652f4a24787f94d41'), + ('\x77b690fd20d9bfa4280cce3de7edae056377d6f6'), + ('\x77bdeebdfb11857562561c697fc61d00da323e28'), + ('\x77bf66d94e3cfdff4d0669fd03ac0ecd84daaade'), + ('\x77c71e7e4e903ae2ecc325822b13a4cc2ac648c1'), + ('\x77ca649e67b2c5ae8bd8e279eb61dc76054e3a17'), + ('\x77cae4044a004cfa7e4d009e608a0488c8c33aa9'), + ('\x77cffc899a7186e31d0bf335eadd7fb475beb4e3'), + ('\x77d61b1ab5759db85e19f8afef6a6f4ae14db8ba'), + ('\x77d6561ab51e2a86318c4505ce4c37c4d2db1dfb'), + ('\x77db2a382dc741ad6815a1c2abafd80e7cd31291'), + ('\x77e253c9bf4ef55ff14d25e9642a83cdb1335eb2'), + ('\x77e34c34daa4b9b3c6e99541203972c49ed16231'), + ('\x77ec40cd9a5197a45e050e1aa2058400c6f742ef'), + ('\x77ed4e3012d822c7cca5c17efcae308b32b8cc2b'), + ('\x77eeb141cfc92aba9dfb5c71adbd1808f8e06906'), + ('\x77f48ade4494edfb5a1fee23afdb293ea896a9f2'), + ('\x7807a1df9d7bc107e984737d9c9ca2fe696a3fba'), + ('\x780fe1546e2989a568f3c438c604245ef1b8d221'), + ('\x7810208619326660587eed5e48aa59d1b05012a3'), + ('\x7817c7546bffab4aac4cd78f011098a0973731e1'), + ('\x7818a0f8aa774c0558890e614a0c160625ea8d12'), + ('\x781b29da8d957aabc6a1c6d9c1056049bf00914c'), + ('\x7821cf7e8ae49878dc6b65189d8e567f5a4e9a0e'), + ('\x78237ce5340fb05f571e0d6016910945fc4fe541'), + ('\x78268cbc8f15aa3eb005110015565625c3d66aa5'), + ('\x78314675f6429c7bcd6406e5ac7f511754a3caf2'), + ('\x7832e27280ac6158d6cc0f24609ef28c0974bd9b'), + ('\x783898c6ad748e982abb713daa22560438b4ab7e'), + ('\x783a67dd3f00e3c5ba2ac0833c9b9965582fd670'), + ('\x783aa2401dd0d50a54a7666003ec3444baed3299'), + ('\x783d423f61bf8d57e3219c53fcae0f8e3442c9fa'), + ('\x783f9606a46a23132ae3201a30c817c7cf68ec2a'), + ('\x784b6a53b111304ea6ffb5b39046958205477b15'), + ('\x785bf23cbcf0f0e55991283fe145dd2817e4daf1'), + ('\x785e064745864f9bd51f62b6e87867473c89b98f'), + ('\x785eb8978018cf93344662488ccfd3dbb55c835b'), + ('\x786290d70d8315b597eb99366152dc7b1963551d'), + ('\x786499bb36411a827dbe62c0f45034591f54f605'), + ('\x786c7d5de7728666bbae87fc85e644e41bd3b961'), + ('\x786cf10506f38ca5d0060ece96f09240f86104d0'), + ('\x786f19c6ab15a47e3423d07efabde44fd5d58c44'), + ('\x78704a099bad91c76ecb96417137464b0fa96b28'), + ('\x7878e0fab9673cc93acbbc9415600900f6f2fb39'), + ('\x787be82c2b20370f5416560d89d3b3fb43896cc2'), + ('\x787dbfbbc57b73481cc84089a5b8f1e8cfeca887'), + ('\x78825c7b27729e1c27058aa5d111a28809438e0a'), + ('\x7884da8493629095b3fe7c9500ccc7d5797cf960'), + ('\x78874e20d4d48abc7029209791ec3700a1b0a01b'), + ('\x7889d13c117f521970efcd88b04376273575f78b'), + ('\x788a621a6af54c93b5774fa62fb06140f3e8c55c'), + ('\x7890196ffcf47822fe6013afcd8ff8ce1729bfdc'), + ('\x78965c2e81e68896146c4d269a47f1db31848e4a'), + ('\x789702c730cdd507e8b60e07b240d0a93ba11944'), + ('\x789cdfdda226c59c259370dabe25f0593a85c824'), + ('\x78a703c2d965155cb1cf517893beb44f67382d5a'), + ('\x78a964a8ab5f14ac831f54b9536d5b7fdcb31076'), + ('\x78ae0db3bc294c4efd3a969b27cbbfc5cca8ac5d'), + ('\x78ae8bb8afa1146ce792c1154110e6bd2ed81cee'), + ('\x78aeaaa3338019c37c0d45eceb8ea895c6da92e6'), + ('\x78b6bb6eb35f511c69646a6c0ba18bebb80a1397'), + ('\x78b83e7506551724c89ab3f4836a51dfc57d33e9'), + ('\x78bf7924ce0e531c65accf9b9ed6132650647be3'), + ('\x78c71c3861670499480e83cde1d9a196f7b3389d'), + ('\x78cf0e563368e93cee8e876ebd65e6ecf7788012'), + ('\x78cf69f8316a579617ace77e31070a0f64605b26'), + ('\x78d7393141a2d3285906fc780b355568655585ef'), + ('\x78e8fa93e24846c1633d70ddaf3922f26c0b7479'), + ('\x78fd7a7b5b4ce8849284a9d07d724be5dd8c3954'), + ('\x7901af034dcaf7818038ad75d8009d0163c54f57'), + ('\x79039d352a79ea303c9d121392cc2301264fa79c'), + ('\x79156307d35848ef1f332be9e0f833537db32874'), + ('\x79156968696bcdbb9f3682e65b234c3e6f3deb00'), + ('\x79199a7a53db01d582a01ea85ef149866f9fd9ad'), + ('\x79264b03c42016c673d4007e0a7a9f365abd972f'), + ('\x79273f6f555cfa026a99aeab8708f85d9d38546b'), + ('\x79291bb9708279e24bc5d994043add61a2d2e75d'), + ('\x79293db4558e93b2d5e99b0333ed836229091abd'), + ('\x792b73544784c5f74cc2d8e84c92c96e78fba39f'), + ('\x792d64ecfc30aeef9cee37df92fd0be1724d6df7'), + ('\x792ed7ceec6d5805103039fcf71ded81382fbc16'), + ('\x793096608044671128dd1b251962e29c3a68a154'), + ('\x7939b77926e04317ee1d9771ae6d99bda45c35e9'), + ('\x793df823e22175387192aa412124547cf06bc232'), + ('\x793e8e8d28155d3c6df44c4ca2fd72398d62147f'), + ('\x7944de37f72525d7f1bd15bfe93bd445d12ad6cb'), + ('\x794821cf9a1f7f04b58a11180b5fc33bca50cda7'), + ('\x794e465cca622ef1567e2b9ad79211cb90dd29b3'), + ('\x7958464d845f3e658490452e0c5856aefa562473'), + ('\x795aacdc026acaacb5a541e4520492afedfbb150'), + ('\x795d3d72ad3dec58d86ff161939154525bc17f3b'), + ('\x796235849b5364d166066023826509177b36b6ff'), + ('\x79632b2e2e5b942de91c8e87d94e1308c4701d42'), + ('\x796367ac1e09dd919d15e5581c102fdefe7dc538'), + ('\x79675869de72d39e7c579fcad97ec7117c2c7159'), + ('\x797003a193f05ed2b30e5a9eb0657ba421ed25ca'), + ('\x797270696a8cfb955ebed49705940b8fd1fa7d41'), + ('\x79747fa3ec2c9eb2ce6ab12c788bd2c71da07fb0'), + ('\x79786fe4116ef7321caeffe91acba4e1e1a58647'), + ('\x7978762659fbd264253f579d4fa53de769006694'), + ('\x797b90a87737cf395eea4a3d7b3d28e092afe6c8'), + ('\x798c4cc4a539f377a667a289acbc31c31328fd5b'), + ('\x798f94c16a714bf5a3080ebfc87dc2897609cc8f'), + ('\x7997b8f1b46f174c80f7b02d8e9e00668e30aebd'), + ('\x79996f64dcd826b05edb9db8b5a7eb2ecc637365'), + ('\x799b2480dde865f4d825a1bd9db4487107b7bf87'), + ('\x799ba72976687d71baf6266c8c5404b76d89930a'), + ('\x799e015705b36ce22177ab87b720980dd04873e6'), + ('\x79a5873a3845739000fb307db31bef78113b5da5'), + ('\x79b46648c1b4fdbe231733a0d425c9f2736e2ec4'), + ('\x79c326b9f7b82dc280ee93884b1c16146ac5cd2d'), + ('\x79c86864dc25005579516b2573d944813b9bc9f6'), + ('\x79d1666df28668f809c50bf1c518077dff6d5037'), + ('\x79d183c36f032a901443b3a3c4e621fcfe90985a'), + ('\x79d7038d5be166ad6ab66642f4ae41cef6edda44'), + ('\x79d7f3ef39fb176b3107be84bc2b653c24fac6f6'), + ('\x79dc43cd8a5529ab73b3a995bf4efda008ad69ba'), + ('\x79de0214d200a62a6b171d721331fd2486bd1bec'), + ('\x79def3c0eb771f5b0ccfb7af609ffd547ce79e8a'), + ('\x79dfb6c57def74bd274fdf998c89f6588418e6dc'), + ('\x79e108acfebd321e89cbaaa40ea9d6b4a046f021'), + ('\x79e5be3ad35ba4c3d1f0ba453217bbb612fd104d'), + ('\x79e864432be3b6c8409d6f390e64b68605de0793'), + ('\x79f4155396110fc87df8eb6054dbc5dbbc65d00d'), + ('\x79f7b27b643912fe093800db0e83d367685b0653'), + ('\x79fbb519d31e21eca4afbdf6922a7e713b81d585'), + ('\x7a00fff3145b276f1b6862b85622d5b389b5699a'), + ('\x7a078e8c157d103c9e4c59151619521dc39c0ac1'), + ('\x7a09f9fcc83a648748b2d09368e7c9b4218b1754'), + ('\x7a0baedfcc3774c465b742d22ba9c49f3f06e5ec'), + ('\x7a0d0c0b4f5bea453a9ab3ff25dc80ca87c18265'), + ('\x7a0f92590ad6e38aae909bea20422dd55012a765'), + ('\x7a124f7b7665bdd774764fe12c27af71d963357b'), + ('\x7a15c485b8f0fe135daab8842ef289231524d6fd'), + ('\x7a1a7844e0d4e3b3c0720b27da1891d89343e375'), + ('\x7a1afae05c46b3b06e52404bf84169f060d4de72'), + ('\x7a238be481967dc91e52464bd1fee1010e573f5e'), + ('\x7a254c6105c4a64fb470da38e5437a2d00b8d00a'), + ('\x7a287726b4bf5143907330fd2f8010f3a98cb46d'), + ('\x7a2aa8877ec956b9f4b69cbaee7f38923e26b6ac'), + ('\x7a2b5db262294216452516f0f394994074d75fa5'), + ('\x7a2c15b6bded7e8dc702396541355d268b5a38f4'), + ('\x7a2e278b16f8f1f3f70a8ac6b5107ebdfa4ee58a'), + ('\x7a32fe66bd5c2d2075bd816104e8c5c287c02fc9'), + ('\x7a39fa8a63ecec9b63b6223cab0e86d6dfe28742'), + ('\x7a4068531a2e0da153daf0d28b1cd64f041af55f'), + ('\x7a4122a698d9e89f6ce63c0e2a62e6c16152ef5c'), + ('\x7a43dcadb3e121094530ce82f4297863474ef494'), + ('\x7a4573ae5ffd9e064571f4f08423be48451dfcd0'), + ('\x7a4ce4ec75bbfa59302cbe271ee3d149f97f014e'), + ('\x7a4d408535e34d456f73af5c4670a1a1591de383'), + ('\x7a4f72796999e3aecde0d92a4dd2655b4c0f9fd3'), + ('\x7a505b8554136882822a9057a415be5f1e008458'), + ('\x7a7577e1f36bc5975a7b5a17c78578bf44a60938'), + ('\x7a7cf33980e68f3db82fe37f269f466455cf0cd5'), + ('\x7a876c839b4f6b0fb5e9cbc29f27e1192f824b8b'), + ('\x7a8bbf3d97bc41759b1d43047706759cea439d0c'), + ('\x7a8d8e92fa7d744dd9d338594bfba7be20b0396a'), + ('\x7a917aa30e4d644ab2b20dd04a7103f4a03a88bb'), + ('\x7a9a93ef6be43f44fe459fa153d26e81d4807dce'), + ('\x7aa4612f1c1b4a7e0e70a99f0cad9948f02d1f3c'), + ('\x7aabc24a210d2345dddfc5c4d506a7e17aa32616'), + ('\x7ab05f1f2489a3bf3f619eabfbbf1d2030c2599a'), + ('\x7ab26e0798cfab08bcca1634fb6d38396a5d3a2b'), + ('\x7aba65daf8772689c2f408f79b86b41b77349e69'), + ('\x7ac0c333a4c61d84b11636d06436f47d5359ccef'), + ('\x7ac166b2e4c65fc01d96cbd7630e40182515ee8c'), + ('\x7ac31b150323c9c68b536ad315164a149fb9165b'), + ('\x7ac41c6ab53fdab89b3cf4be25e05e8569a64284'), + ('\x7ac46814f58ba5b9675994f5feae3f8c9e9b0cf3'), + ('\x7acdc2b67e52506c236b02e18d9c60cd0403df3a'), + ('\x7ad006525f92ef8f2a5db0818c403fdf351f5917'), + ('\x7ad3340f4d7815569f834682a65fc59abc091bfa'), + ('\x7ad3c890a01e6d372a5e7b35b79cb4eacfbb52a7'), + ('\x7adb5a7752f80a7cb672fc19fa6a234456542a8a'), + ('\x7addd35a08bfe6be9e337ea1829fa7c8fe01c2a8'), + ('\x7ae308a34c0c4a5b03eeb5b4eb5c8876dec7cd11'), + ('\x7ae45f667eca2f08108f61e040162c3997cc5b1e'), + ('\x7ae47cd26aef26a933681da5907ddc2a5d1e5a04'), + ('\x7af571d3320fd56d52c45427c83afc60152720c2'), + ('\x7afda8ab0bbcbf63ec89a3c6db2aef0c46f9445d'), + ('\x7b047c26c7a5a3f281525879e5d7cb1352adc4e9'), + ('\x7b0921a32fc2e1a191be57b577ff534ff444a16b'), + ('\x7b0a9cb8426289f026ef0b5287d4fc51edffcea1'), + ('\x7b0b36ea021d024a61c9bfd0be109420bc6ffab3'), + ('\x7b1e1eade0311c95f7f628ce6c471b0e82b65633'), + ('\x7b230a0db6a7b2aea79cf3ee9816949dac4523dd'), + ('\x7b23c6235f681399c31c0163dac89e24edf90135'), + ('\x7b2431ec724aa6604f14fdd2a965a8af5b62c831'), + ('\x7b254c41d1ad0584ca182d511190212b6dd15ed4'), + ('\x7b267d47f51af70e2f9680fd6149a77e18962b5f'), + ('\x7b26cef3a094e484792a81310658e276b6b43305'), + ('\x7b271169908de2c902f1cf8e623b509fab0aece1'), + ('\x7b293fd29dec18072f16db9ace07048aca91db31'), + ('\x7b29828b53e4d2ca3aa8b8a46a38ee40d5ff5d16'), + ('\x7b2fbf6fde5165a055c945ac60abd5a8ec797e27'), + ('\x7b3295afe897e91dc0c4a8af201130d506f710a6'), + ('\x7b357ca58cd1a2683cfa355ff22e161591293beb'), + ('\x7b360a98cd0265d63143fc0b9c160686a60491d0'), + ('\x7b36a9d7a57eca7ed445502276890251087bcde7'), + ('\x7b397f29a426db4f1177d1707692d366499ab025'), + ('\x7b3ad4821e11a9b6349edd2a9515dc48c9b7a2db'), + ('\x7b3b4bf2056b1b4ad0dcadf8bef25789f78a0064'), + ('\x7b3cf8da89e3b16e617f1ca8fe2fccb3c5fef820'), + ('\x7b41eff1b808161da20c0c64d14ed61e793354c3'), + ('\x7b42cf67253c7bbf9691c8357e1e7e1c1d824b48'), + ('\x7b43cb0d78b6640bbf405ee198a535b170ecf1bc'), + ('\x7b4430dd89e77fc78bdc5837feb2eb3698d37193'), + ('\x7b4889de7c078e960c1ba933c762f561b93730bf'), + ('\x7b5104a0f086ee6ed3439cc504ccfe9c2e6dd3ee'), + ('\x7b529456032abf9132194ddaac62d2d163247c38'), + ('\x7b56818545b3f6fb1ee3281f14cddbe571281d5e'), + ('\x7b57d6d295cb8303d416f68a3cd77a053a0ad975'), + ('\x7b5995cb9677f68824a510c25ceba4ca34592f09'), + ('\x7b5f14d462edf408f6a9103a2b87f03981a8910a'), + ('\x7b657297f2618c51a5954a0af6ecf65028212f28'), + ('\x7b66f9bc96c0971b0e0a788a83b71c71a0525fb4'), + ('\x7b6bcf026b94538fbfdb24ac58fa3e99b69b2ffc'), + ('\x7b74e5e4875b7777bbdc58fbbfcdffad564f6e2b'), + ('\x7b776316fdc3cc867deaf49ed6e906977d5acc8a'), + ('\x7b7e77f2479b1fee69f5ce5b002370d6d5908e3d'), + ('\x7b80bd999566976c07f126b674c6d4fc6f159e99'), + ('\x7b85bc77eb3e13d42452f48562efaccc421485ad'), + ('\x7b8cc5b600f6dab5924be4d2c242bff35e3bbd8a'), + ('\x7b8f7ed4bc75634d927243654ff03ef4537878cb'), + ('\x7b9448801569b3fcc64cc9a40db15cf9f0fa9be6'), + ('\x7b949c704acd5a63605e6ca9b9c547b34cdb3ddc'), + ('\x7b974bffb5449b633924130dfff16009782a5e14'), + ('\x7b9779d8010b5bdb909ab6badb9caecc9b0b0bad'), + ('\x7b98d4dbc4e7ed66cea62e6ddc1c63c8ca51221e'), + ('\x7ba1b2ef11a91dababfc231fcc25ce03cd4c86f8'), + ('\x7ba2c5f0f452c47ad6f590b3b2a9ae3d74a4c794'), + ('\x7ba6cbd80132074661a0d4b2c305647cff54c1ab'), + ('\x7ba7a53bb90de6c04f4a6f6085a195f19ef81974'), + ('\x7bafd970cd12344f5529823373d32b6f208e98b4'), + ('\x7bb22bd001de6d5dbec25dc63a80235fac0cadae'), + ('\x7bbd3aff55d6d0799d0cd5ea9d984906c6a960d9'), + ('\x7bc20e916351ca252f9ec4b5c0a0cd59967a98f9'), + ('\x7bc323e0401715e2d163f3d8b2d3fd6b38657dec'), + ('\x7bc39956732af2f7624ca6ac13d7676e68e07131'), + ('\x7bc5fe5acbce8fdcb278f525146646947333df3b'), + ('\x7bc7647b4954249e07d36335e7ee552595835fbe'), + ('\x7bc7bd590227bec87232d9bb3c98dcd98a6be122'), + ('\x7bcc18af78ea13451384a3913ecd40fec11491cc'), + ('\x7bcdbcd814015d2004f4267535190b29759c3a93'), + ('\x7bce2d37249d0d39fa3d7887172071384bfef69c'), + ('\x7bdbf84a9b6d1696a2c49a6a629aa718264e9d5d'), + ('\x7be210f413c298077d397e587843687554e4a8f3'), + ('\x7be92857897c81cb62310d3e43ce2342f188c1eb'), + ('\x7beb43c8464fcbc5a795ed48bcb9332f53343515'), + ('\x7bebdabc93a0cdc024813ad04910624f7f336d97'), + ('\x7bee3d92fb676c01170ce220493a376c6f9a3347'), + ('\x7bee3e70d3e511eeb6d6db919faca836a365038e'), + ('\x7bf143dc2f4c814221fa7b4ee9835c8cac386ca2'), + ('\x7bfb68efa8ef625d841eef56dbb991b61e638e5a'), + ('\x7bfe699b09b46b4033908792c4fd70316900a300'), + ('\x7c03e970047a0da90633c33b7bce7327488d148b'), + ('\x7c104f356fc02a6e4a4db4613a1e75d61c77e8d4'), + ('\x7c131da7c9c7e465a188984d05e6977cd48833a6'), + ('\x7c26b989b392b3f1ebac919fe1bd68abad12f798'), + ('\x7c273f51e3b9fe3aeb7e73607687cd6570894c0f'), + ('\x7c2b06f3a8027e5fefb1fdcb483432fdd1c166e1'), + ('\x7c2d7fcf3d823cbe6ef2582460546c56a63d4a5c'), + ('\x7c307768d5a736e7bf30a62a1f79687e03ad8c2d'), + ('\x7c376aa58b0419130d0ee06e836dcbcd0bf2685c'), + ('\x7c381bcdef88d7bf33aa42d6758418065ffc8b4b'), + ('\x7c39f0f0c9df7c78b680a9ef561aa38c2a5451d7'), + ('\x7c45dcfa1fe1e9fcfe9d7aca21886e42cbf8cc70'), + ('\x7c4d336edfcbbe089cf223f13e3122736a5b53b8'), + ('\x7c57eb5e583155835e8764c89e5d596cd3ed175b'), + ('\x7c5880d92ed019d73361d4383e3a63dc46f8c65c'), + ('\x7c5c8af2c45eee29a55e44460e43fd61a35fb376'), + ('\x7c60c33619cbabf74e7619f640ff16cc43896cf5'), + ('\x7c6177c92cfe3369af64b9a436cc9496908737f9'), + ('\x7c6b442bd77c5d576c379f34f5a59495a4ab0b17'), + ('\x7c6fac2e575eeaeba7cccfff732c039c7928df18'), + ('\x7c72cebe263280a961743675942b7687d1121ce7'), + ('\x7c72d3479f510fd90964b3d8e102a4569272963b'), + ('\x7c77f4d7632ab6d6f2623960cee104aea3f1904e'), + ('\x7c804133ce0e28c6d03232a9618f1d771f595ad5'), + ('\x7c841deec287bf5bedce48571bea92d62d93e2ad'), + ('\x7c86dd0648be33f0db3f12434013ac91d6b29a3f'), + ('\x7c8a092f632c561c1a27c88bac9cbcb7f329194f'), + ('\x7c94a138618ad985820136aa7c70bb6eb8766de8'), + ('\x7c9c1e862489dc7260abb0ca0f80bafea677f2f6'), + ('\x7ca9f7f7fd651ea47f9bcd77b265903ce3ea6218'), + ('\x7cb1383c287eed148122f6be09864e0c17e2aa17'), + ('\x7cc134bf0696852e6473951daff6f4c391d12d8d'), + ('\x7cc41e3cfeba533e92e479f2f7c28db2f2fd89a0'), + ('\x7cc4ab5742fad6feeee577fa6119fb9b78589e14'), + ('\x7cce3f6ccb02447d9d75855be8bd924e84e74f67'), + ('\x7cd62bba0f40c352d045d8d9c7c073bbbd051506'), + ('\x7cdf12cbf14f5915b52b8fdef0aa067cedf74314'), + ('\x7cdfa90b85a567375329b356a8399988bb1b50d3'), + ('\x7ce0cc80480c8df2435ec23f41d3969842e388bf'), + ('\x7ce11571028c53c0f118e60149f98dcbfdb24546'), + ('\x7cec173118a1c72540a09d4bcc8dd3742559f0fe'), + ('\x7cf0143ca3e498b3d88dbf7798552cb48929fe62'), + ('\x7cf9aa346970e407fba3e09e732ba8a96ce86bd4'), + ('\x7cfcfae2b2d592db65701301e05284615fc90639'), + ('\x7cfeb69d652702689c3817740aaebd0937d4a2cd'), + ('\x7d06c4a8fbd77c3619d72199c9925352cf5c4ecb'), + ('\x7d0de0d77e2824394cb3672c0dad5a2810481c61'), + ('\x7d1122f75ae57b6dfd5f3031220c1aeead59fbb7'), + ('\x7d12aabf2c7491b79cdb1ceab61d999dc3e42f42'), + ('\x7d1ae015b658f22d6d4da7c23dc823088e30a9e6'), + ('\x7d204319240588d234fec55c76c214053f07ce62'), + ('\x7d20f2f71ae3f039667292578336b271bea1215b'), + ('\x7d247e66eef52d548cc2c3dde161000e3e22579f'), + ('\x7d25d91a76124ffd08faa523b9bd2491ed8ab1c2'), + ('\x7d2e4acd3aca7edffa60e069b90648fe1d7b9de7'), + ('\x7d2f3dae0f4df3f941fa167e5a32c8a8fe77fc74'), + ('\x7d4c41b209e29d59e852a8fc73100cf7cc2f5826'), + ('\x7d4de325c7e688ee423177d2f2496a1a812e8461'), + ('\x7d4e90b82c8283b3a1a4ff3add5d685bfd839c7e'), + ('\x7d4fc6ad41e4b360fa946d2ce451149bf06d3c65'), + ('\x7d5050dd64a7388fd9868e5c91eddc505415f5ba'), + ('\x7d516e7ba8e02e480c68c41ab360d0aee9438720'), + ('\x7d5ac39afaff6398649b3fd3d70542def37c09e3'), + ('\x7d5ac9f29304b71befb2840348e1a72a8769ee4c'), + ('\x7d5ce3bc1dc5c1a8d718b6a9ccbdf928b3e9cd1d'), + ('\x7d5d3fd2fc6be36436fe52ed724237550870c4bb'), + ('\x7d68c38979a899f9f2f117b0cfa63d56ba33b052'), + ('\x7d6d67e33c0f42e7a4b6a25066649e348a24f992'), + ('\x7d6f5047ed471e146caac1b7317b65562c147025'), + ('\x7d705ec6c670fc37305289d36271b60b9499474d'), + ('\x7d7266e8bc3bf6d7c132fd4b8308b90ba12bcecd'), + ('\x7d8250d5853fbe89c5eb3d6b5d8295182ea3a01e'), + ('\x7d857f62b4dbc74eb7b0abc2c63eb42f4129c7fd'), + ('\x7d86331ea62ff321257dba7a73f2c8a52cfc4a0a'), + ('\x7d9453ea1cc38253080c6dd95a6df1eb855e6c48'), + ('\x7d969f2353469ab9b9a469b08b305f1af3ef7482'), + ('\x7d9a6c4c32d7e920b549caf531e390733496b6e0'), + ('\x7d9cae4b9074b7ade24b9fbaeb2cd72f5e79ed8c'), + ('\x7da02cc26d7e80fe9902aeba57874c17a51d68ae'), + ('\x7da410b8f41e07a9fa5a0e86475a21316f579543'), + ('\x7db2592a5962b97a5da4349cc39cc05503f41778'), + ('\x7db5b4b195544f0ce257825fd52f8a41e83f16c2'), + ('\x7db5f914167cd5e451b52386e091ee49b6dac66d'), + ('\x7db98a61cb055b8c6bf28d0bb9286f9849e3d328'), + ('\x7dba40c321c179bbe697194a0aa9965a18628df6'), + ('\x7dbada0b8462ae3a5a043a9b272f8805e85b0f25'), + ('\x7dc33b3d86097da3d6bfabbfab018cb8d75553a0'), + ('\x7dc54c74d14cfaac73ce4115924415c385a63a78'), + ('\x7dc60eced7b68ef320db0a5b7717777735478408'), + ('\x7dc8d1eff79a1e103b3c85f0f6b5b9184e58a4b3'), + ('\x7dc9ee0649cf81750533177d95aab05f9a0e6dcb'), + ('\x7dd3c54f306918babf2985f6a7ec1d861af319e4'), + ('\x7dd8f410f3fb82e0ab2dcb3cc2149fee2e61651d'), + ('\x7dde9791b8503c6053ccb4dd97273f50fb9f22b0'), + ('\x7de72af302f92f340c1219d75db5b00a54aec969'), + ('\x7de8bb1e5b6ede95a9cdc43d3de25a8b71b1f5b7'), + ('\x7dee027af372be1c46d7f491f0db9504185646fd'), + ('\x7deece7a608838ca326ddd7fa541b0be901f88bc'), + ('\x7deedbd75d811cdc626a3f951441032fe7f1c4e3'), + ('\x7df495ebba3017402a4d2ade882f9b733d99a41a'), + ('\x7df960051727daffcc5f803de00bc93fd9bce0b7'), + ('\x7dfe3baf5bd85fee8d6534309320e49120d301ea'), + ('\x7e00511baed6094bc082c12892805f165bb5abb1'), + ('\x7e0091fdfd2246f4a5689654510e32ddec828a1d'), + ('\x7e01ccfc3331e331f3f911091d712be5a5595ce1'), + ('\x7e0329e75ee5b46a357c82e8d622440bfb24e2f3'), + ('\x7e05519b859383c2752fccf26a363a4652f32237'), + ('\x7e153a1c701a36ab6c8ac4f9db6e57badf1e627b'), + ('\x7e1bf7620ff220ede1c11992ae3b21f90e204c7a'), + ('\x7e1d37fcd7795e7ddbbf49cee61b0a2cb8ff5906'), + ('\x7e22527917bf8b8e346840e2104e2d858d98cdb2'), + ('\x7e2568faee98836132da6863f8e9822457241665'), + ('\x7e2772c45c0e5f99a708a6bc918e05969d4ef810'), + ('\x7e28d973a529ca57570ec3b15f134154f1c3f7e4'), + ('\x7e30e13f6fc6122de1388195af2fe51eb6b910d5'), + ('\x7e361c53007472bd616d58aa148583586e4ec409'), + ('\x7e3b8afc30994b7091bd606048e80dc18a61b4f5'), + ('\x7e44436799e0d72b240f4309704d0279b0bea4f7'), + ('\x7e470031622d6cc0aec39d8d73166583460b0c89'), + ('\x7e4c709dbf09c466d1a86d0225adfda6fe943231'), + ('\x7e4c7d71268ffb84c4c9c9ec774d1f4d874238a5'), + ('\x7e4cec5ee23d2a77c6cf1f6bf78a6a15ff3f021d'), + ('\x7e50f7b126bcb14d9da31b222679ee43127f0780'), + ('\x7e550b87b850bf37ad0ffd49ee2c0ffa66591fa3'), + ('\x7e57213f211ce79155579e20599a9cecc47434d7'), + ('\x7e5963245800bda7526090c9b9a3419f908cbf25'), + ('\x7e5b004f3bf1e39815de4156b71d60e88c9c61ab'), + ('\x7e5cc0b65b29343a655bac848f89bbc8d0a843c7'), + ('\x7e5e6a08e7a8d19785e6d98ec2b9cdadc0faf1a8'), + ('\x7e5f3daff16efbf6a938c90e493ef407a775b840'), + ('\x7e5ff4a4013ab286f371708e19cda595fcd69769'), + ('\x7e6064330292c238380811412e9d7eaa3945f73b'), + ('\x7e62eca29dc71f08f21ee753fdd1342c32ac3a8d'), + ('\x7e64de63d39e430cd63766febe55593102127f12'), + ('\x7e654c0f18ca037ce891ded2e45ad8146225ce34'), + ('\x7e66ed4547af9a4ae963ea5e734cb5fa7594d404'), + ('\x7e68194e71e2c1df20270b6a5978bbfaf89c8e1d'), + ('\x7e75feda155788cf67b20765072f5cf3b8661fe4'), + ('\x7e7c2dc72e73f54a17d82aa2e926bb9c8ad90d3c'), + ('\x7e7cf1176d5f265bbaf9666e5b4f92559d1b12ce'), + ('\x7e7d2d0bf3befb5323978dec24c2327137c449dc'), + ('\x7e863c6bc0b69d2e290a68030bc618aec9b58839'), + ('\x7e875f5aec6b935523945484076fb1d75d8cc1ae'), + ('\x7e8acb21e8354822161da47fb5caa3563fe6edb5'), + ('\x7e8c8928f29088cb0c4f25d028aa3fd4a35e6039'), + ('\x7e8d2809ec7490253125a62916de74b3f25d90a6'), + ('\x7e9a446ff3b8ddcb7cad31579db02947f5ca22c4'), + ('\x7ea3eaebe76c4dbbf6aa1bda5f23e62279d13b2d'), + ('\x7ea4a34965c20618662e5c5b073e7e43b9150086'), + ('\x7ea72d9173234a3b991daa04b6b33476bd045624'), + ('\x7ea851839283873a97f6b7821061407d617f9fe5'), + ('\x7eaa4be3772f2f349d8e2da3236d50c96ab6a56a'), + ('\x7eab32b46ac082d56d53364e3e9fdba37d0cfbe2'), + ('\x7eb141e90a72684a81296a9b19c82ffeda145e4a'), + ('\x7eb442330ec0fb2758c65ca830e1b6f3160ce43f'), + ('\x7eb5a08b935ef228cc6905761fb4e87906178154'), + ('\x7eb5e351448069a5e5d393b7b47631eafdb2d6ea'), + ('\x7eba1461c864b737006f04c0c228b0a383bc1c30'), + ('\x7eba7bea95bc595b6b0f439c6f1145889d9f5b3a'), + ('\x7ec4e69b59aa2d0eaf335dca1737223a4a0d4231'), + ('\x7ec588b2cb94276892475c56b517a03021ed8576'), + ('\x7ec9797e1732ea565f09d85424709d1cd05ae740'), + ('\x7eca5d422da0d3913f4ec70eee7c2ca5f1584514'), + ('\x7ece280a8c01a8d400735daf62e4257446b4e6c2'), + ('\x7ed7eeca55631344c3f7019612a5cfbda24282a7'), + ('\x7ed9ce686906f1a32cd425e85293342af9446565'), + ('\x7ee1a07b8124728c384843c774a369a152258c28'), + ('\x7eef92e9a2d1edaf21d8a323ac024f45db4289a5'), + ('\x7ef11391e35352a9e5bddd479d58cf1af2bf5555'), + ('\x7ef144e61414643795825613549f2c8aabb5bfc3'), + ('\x7ef6b719e73c84cf1545c4d41ca2d5b9394c806c'), + ('\x7efb52a8ea728e4eb5f037b841cce369bb0ef30c'), + ('\x7f0464c21ac60b1c8e3b49f91959458d6b4aaf8d'), + ('\x7f07743772d3663ee568f66f6ce62ae9f422af76'), + ('\x7f095be49df09253d26fcf4d4f36a65ca1ff0b6f'), + ('\x7f097de214c70216c67d29cd38ccd388c1af6d7e'), + ('\x7f0c61273c5534ebf4319c195a82fc0d94698cb4'), + ('\x7f126182d57bca5172b355d48f7a2f556249fb3a'), + ('\x7f1457e908cb1decaf9666bc447f28f1856ac3fc'), + ('\x7f16a76b634118f7acb7a73ff2e826ad17040634'), + ('\x7f1e77834cb3bd819480de13c8fdfb118ce89b31'), + ('\x7f1f049a42f49586ddd38e51186a5e7535229926'), + ('\x7f20baf74bbf05cf6043dba733d16f75362b2675'), + ('\x7f265a7f114e26ee7669b8d90c196ddeb7fcaf2c'), + ('\x7f26b0e08ca3052045e6b28ccf1840b354673454'), + ('\x7f273bdf74cd0089c8d27d86a67ebe062e7ab9b6'), + ('\x7f2927b36da092242fd91f033384194e47807e75'), + ('\x7f32ce911d43f54c2ad8a005ee03bc2b7809a58d'), + ('\x7f335864513a678d480bc4bba5ec82b2707ef4ed'), + ('\x7f341a9e0a901eba92dfdff83d593f35e3e70505'), + ('\x7f35a5b3418cf0847c64d7677b268b6e80362b27'), + ('\x7f394e7fa20764c7c26b1ae33b6d8527e489ddc1'), + ('\x7f39f9e500129e4ada259ec05bbbfd49aa2de7f9'), + ('\x7f3a4d0fa729a36478880170a5fa9ffdf71729cb'), + ('\x7f3d30b4d58a3e90e8bb2753454150da87185763'), + ('\x7f423811aa8ce7a987007cbf334242ccc702f290'), + ('\x7f4696d40b55a73c2d2a16b5024f026f5dd37121'), + ('\x7f4a2884f5cfc0bd8e4df53aeadec724714cbd17'), + ('\x7f4a5605093e746a93add02520137d58cd94a7b7'), + ('\x7f50662071da02ef540e51981f0f584c15b44b6a'), + ('\x7f51c3a6e68a21503ff6a52be7a4f04807668839'), + ('\x7f56ca0fe1d73e048dc7bcccf91baa6a1b3346ab'), + ('\x7f58d8882e8ed7f1801e09dbc6fd94b81a00a703'), + ('\x7f5ca3484f3b9ac33a73e42309799a7c67c7bd00'), + ('\x7f5d728376abbf4019f937aa608a02560f157353'), + ('\x7f5fcbf176a01f07054b732439f4241c5a2c0a87'), + ('\x7f62d1ada9ff733927cb54e51c2f5401f021bb5a'), + ('\x7f6c3b2460affad45ad664ab46b0bd3f8295aef0'), + ('\x7f7407fd5d6c3212ca75a1444dcd19f8e9aa2ca9'), + ('\x7f7aa2fffc93a942993da7c8d27323144e84c68d'), + ('\x7f7aa5c74959eb938411a356e0ebf031ff3698bd'), + ('\x7f7ac55e6f79c51ac3a4eaae219257542d12f50d'), + ('\x7f840903ad822e9a6c7521cc2af90da3569deef3'), + ('\x7f9284d9ec673db63ff4abf14cdaea766b435b9d'), + ('\x7f96ce6c9a4c2c8aecc2cc0303564c4fac243dac'), + ('\x7f99e3136c270b3b7622b0a9157a7c88e999a394'), + ('\x7f9fc74a37c880a3f8d95aa3cccd5b34bc347e4b'), + ('\x7fa5eacaefb50d25c5f82aca242b494a0f36fd52'), + ('\x7fa5fc43c3ead34f026ae63083626a4d7a165574'), + ('\x7fa760c264c3a907a250ce8ba305adc214f66813'), + ('\x7fac4ae81240f9f6a0e97483981b9e0e907f9c33'), + ('\x7faff08e2e933f07aab5455cb2b4762ebaec8f0e'), + ('\x7fb5ca74b4e255dd65a994250ef76c77b5429e08'), + ('\x7fbf990906b6d8b3b95c6ed1a4cc9b4f3cc0fba5'), + ('\x7fc31aa49e35bd4c596c0079a942406e725a49b8'), + ('\x7fd57ddfc51a8e22c29af956cabffbc1f802e142'), + ('\x7fde05227b17c6bf97a2ec91301c1e01c04d42b4'), + ('\x7fe0ce1b20831d75f2afdbc260d6f0aed90e6306'), + ('\x7fe12be0067f4d7e83630d3b41bff3ebda50ef1c'), + ('\x7fe5779e66550349672042014fdf6553400e3352'), + ('\x7fea990a8eb66c89c9e2ec73cde7b649bdfe4376'), + ('\x7ff0b5146b4a4cc3c3dbb8bd82d5b39dbc2e5e38'), + ('\x7ff88f22869126cc992030f18e0eeff65ec8bbac'), + ('\x7ff9d766b0a03a38fd54f9842afc0dc6895b2da0'), + ('\x7ffa1ecd4edcee76e2a524a162200fc99716863d'), + ('\x7ffe2049f42334efe68d2fd7ecf14650eeff6575'), + ('\x7ffed8528752e406c236e0ee05e545fffa48adde'), + ('\x8005b17566185c2bff7887e47534c9d3170a5e97'), + ('\x8007ad6bcb7e1268f1e2c0e393827ec8f7d703db'), + ('\x800bd390a1bb9063ce62374679c1fb0590acc3cb'), + ('\x801214f6dfa27693c0e91cb50295866ab838c744'), + ('\x8015953026336434aa087c16cdfbc6a9b69e83b5'), + ('\x8016869bf699c56d43d83d412c1f0d18794e687c'), + ('\x801d18fc4898023a060e4c78b7178358a343e693'), + ('\x801ff4593e8efb6122d9df20fdaad634514c4d02'), + ('\x802782e5ef95dcfd3208cfe3891fa39f4d87cdf0'), + ('\x802e5e66e4c3aaabdb24c60aeacff0a32c1f0dd9'), + ('\x802e70610d0aec51c86fd55398d2e9899e7cac4c'), + ('\x803231a73873a318e29318317fa801967fee7147'), + ('\x8035ba0c89dd9059c3e20c9ee33aee96ce1353f8'), + ('\x80422bcb3caa95feaaa0df164def94de38d12162'), + ('\x8042ecbd874c8dc97deae02a9a66dfc089c3f8e2'), + ('\x8048d3d70d4e8935333c72bc8dd6d60c9fc49fed'), + ('\x804e507687262eaee35bdd19a4b7113ef3350a94'), + ('\x80510f08a8e81e9abd723528e6e7216181f7edc4'), + ('\x805a23635f1dbf97093faba9275c19a2eedd836a'), + ('\x8069c628f2f604590d45d05f9fb2b4cabba83494'), + ('\x806ef76833dd186a404c732e0944e5548da7b88f'), + ('\x80750211c95dbe238e97e576733e4fd68d032bab'), + ('\x80754efc1c6c6b4c1f6d513764c83f484c0113cb'), + ('\x80770e40de3724ff6992f4838bf61654b0d3710e'), + ('\x808843183f8783d5c345199704027c726a462abc'), + ('\x808ee5fbb04cc5a8b324dcd056ce4b41661e0174'), + ('\x8091206325f8b8241d46972e9dda11aadaeb0e4d'), + ('\x80a4d0551457f7d426511a9693af7e4af1cad0af'), + ('\x80ac676e304a6a457eb2ba7d2242fb5fba9affa7'), + ('\x80b9799351ab5e09053973c12a682464e66f6082'), + ('\x80ba474bdbf007bb45f1ad40c271fc0f94dcda98'), + ('\x80bb0f62efb1b3f2ba6c95c568824b45f12415ad'), + ('\x80bb1134879cc6501204310aae675267fae4d0c8'), + ('\x80c463d1d3af3aac1d6e55a6d6876ec861ac79fc'), + ('\x80c5c0d7032d5361bbca65158a573688cea934c1'), + ('\x80c7f79ea610e4e90ebf816744ee288cbc5ad6f3'), + ('\x80c879dc1b545be217a478c9e996b68273bc7e14'), + ('\x80cbca7dbbc55660f2e2db358eeedde7a9c4ef5d'), + ('\x80cd03da3e800e6716549a21abe3c1dbf517ada8'), + ('\x80d333210c1ec0f75729d69d40094526ddfd9fd5'), + ('\x80e3b3fa0e47764be0ff33298fc8a76dbb4971f3'), + ('\x80ebf75cef709b9f26e2235f33598f24c373c61a'), + ('\x80ed3bb23f54f6658c93f238d92e55deedfe753e'), + ('\x80ef0e0c845f286be9e568b3a5de472c5a772d51'), + ('\x80f1c9a16e61b6720fdbdf3d2f6c67877485460c'), + ('\x80fbfd311fd2121e1db888499c58c251638db93a'), + ('\x80feb6cf808113abfb67cfddc80a84a611a9536e'), + ('\x8107cb2100c9fc50fe08a8bf16bdc5b00cb1d9f0'), + ('\x811034f8b720211fb64c9710b5b060075facc48d'), + ('\x81104f5f9ddc40077f0b27fadd1e9b084c0dd263'), + ('\x8116a366e8c8ed7cd163e17b3667c2b7cf886748'), + ('\x811cea8430204eccf2a839f684102cef906b5f7e'), + ('\x811dd0394b03db6f6acaf22c889213fb65e11abf'), + ('\x8120430ae3baadb57fdcb1575d60a81a9526a858'), + ('\x812dbbe1accf707ee6ee401fb056933550b8b565'), + ('\x8132f0f0a393d5b8cea7bf480d6b28e0c3688c0c'), + ('\x8133ca6ed54f07cb4f35eb3d0e1d5d94e0926edc'), + ('\x813c5114379c2db10dae3ad4788eef8fd2672938'), + ('\x813e763c64e7abdf4ac79ebc95d0e02198050b47'), + ('\x814c412193d0133963bb42b5c1b60d3a028b4dd9'), + ('\x81512c70ca56ba9971f9c734d6071c9b0a383a69'), + ('\x8153619f4e686cda58a5f0fa9681ea55e7c13a38'), + ('\x815a4d754ff9d087c8c4b6099674fd6c5a6ad9bf'), + ('\x815aaf040ba80c16a7761561b8193245b6ec38d6'), + ('\x816168eb38c748d7b3180ac7c2284c09e47c27d1'), + ('\x816527c82e273d984df0e8153d5e013f6b82aafa'), + ('\x816b63a9b121d3ee04a697d2cd30011747f45a34'), + ('\x816bbc3ffc586f2d855ffe90a90be8b03b8e3b0c'), + ('\x81744bcdc927a1178e8e2a3e6b17be492eb98b95'), + ('\x817843eb6f8995065bbf41005d803ac93988f2d5'), + ('\x8178c76d627cade75005b40711b92f4177bc6cfc'), + ('\x817910e00214d024f55df833355443e8b7caad02'), + ('\x817e333c5772ccb5efa8baad1b1aacf762720c17'), + ('\x8184881fe174d15153705f1be8ac27ef6772b8bc'), + ('\x8184ced8cf853a64c3aa6f9afd722cdbf597c38c'), + ('\x8185f2a86dc7b0696e326d25e65e5742ea44f1a7'), + ('\x81895f8ecf2ce09097f9eb7ef0c7344dfb6a49af'), + ('\x818e802ef43b389ad1b79f580ff3e1d33429d2a1'), + ('\x819421465096ca0de64ef90efd94dbdef697e376'), + ('\x819430586b1bbb33c94636f07633505e87a6b85f'), + ('\x8195a4e789e6aa61c6243434ee401079e4dfb844'), + ('\x8198904146e106722ab75613922c42811e11991e'), + ('\x819fe0e886591fa8a9f954e46da886d02d22a37a'), + ('\x81a6cb60e57ad8befd3e838b7f70a99e013eb1fe'), + ('\x81ae25c4362cd22b5b91a25ec072b4ae13b1ce42'), + ('\x81b5476893a72b28c40b7a9fb54ebb098edec05f'), + ('\x81b7a72c8a0345a582ff5a10d2bfcdf441ea49b8'), + ('\x81b907f5502f61aeb3a355ebdfd776be6c5582b9'), + ('\x81bde974c68e494d2aae2c37713b1921952d74c2'), + ('\x81beb924adbb21b4faf1c604dae0069edc88cfb2'), + ('\x81c05613864033d6d47b5b1102113ed2b55ffd14'), + ('\x81d2e45dd1f32162cf15110a69929604fed175ef'), + ('\x81dac49dc128aa0a7d0263d24c0d1ce14de554a8'), + ('\x81de7da7b2f55547d45cdf0fe6193434d6c4c6f3'), + ('\x81df31a33fe3b5d918c6abd5539189f748482a28'), + ('\x81df5cf60ad64e03c3c00e11efe755247ec6c83a'), + ('\x81e6b60293b364818d60fcb7220c79f37db4f96d'), + ('\x81e893f4e6c782d9fde61f7e08088798fae7ee0d'), + ('\x81ebbaa200fd5605479df72d449c9d9edb063dd7'), + ('\x81f03d4153c5048658837982d7ac80f71f972866'), + ('\x81f49ba40312842445aa030ff4cda08706325c0f'), + ('\x81fb24b5e7c25af62d634013a7b400e1b8e12d53'), + ('\x82027c5defb3e07bf8905d24ceb0ec155bdf81d3'), + ('\x8202efdc091a10421b3c4d0e0046a6a881c8eb97'), + ('\x8207972ae423078a507dd3a658ff9590c1544ff9'), + ('\x8210ea03aa837086e47e755e854bf9dca70ac0ee'), + ('\x821195d8bbb77f882afb308a31e5f9da81720f6b'), + ('\x82161af87d2fd4a0d872aa900ae692debcb3aafb'), + ('\x821c22e080a10feee928bcaf458fd6912811e812'), + ('\x821ffc9d390d85dd51cae19ab516dae587547f9d'), + ('\x822491edb993b072fc183a2ce23adfa8c94cee7c'), + ('\x822a1997c7b8310e9631f5db5cf1038f2f18d785'), + ('\x8232d28bf25c732e388525f08ee4c5151e921f6a'), + ('\x82352b5db688b01f46e8322f48f3eac9a452bef9'), + ('\x82354498956954be8b98d6eac71e3beb4298b039'), + ('\x823920b3a995a5672fc174b1d385197a698e642e'), + ('\x824c8dbdf945c0d20ad29e60080b07c4383f6647'), + ('\x824d30f9e91009d4293ef7fd4a7905c429185aeb'), + ('\x8252a29b5f3510bf527624415d0fe8bf6237f45c'), + ('\x8257c8dec7be96585738d7d25e455b23f8af8522'), + ('\x8257ca79239174b8442c0aa2bb15d08eaa7e71ae'), + ('\x82588dc01ddf5905a6d21c8906660b24f8e59370'), + ('\x825a251d12afef6b6dfe6481a663da4acd70910f'), + ('\x825c0b706d36a0c0b150d81e852f9c06d98873ea'), + ('\x826686d21b45e1565a5c1b401652af86d2345094'), + ('\x82699fb86af02a61acb8748bb47da405dbc7f7de'), + ('\x82708e2a6040ef2d72d98089ae494b6ba34dd156'), + ('\x827e7b9cc0ef7496ac07aebe08f4bd1ee67c086a'), + ('\x8280e79f1747ea8b22dd0f06bf03110b93613b06'), + ('\x8283d88bd4080ad50bc3da263e4f9be2f5496428'), + ('\x828484c1bf381d445263286ff1218b65e10b43e9'), + ('\x828cf637b5ad7ca796c5a5b0c1f3dfa271f6a2fe'), + ('\x8298123f3052f900bcee1bf59688735c9b86596c'), + ('\x82a032710160e5736dbc9cf3e0d1e1cb8dce5ecc'), + ('\x82a32a69467d412d5aa2b712b442f092bf51cb84'), + ('\x82a44b31e913285de9ff9a8226ce85d3d93a1df2'), + ('\x82a492dd70e19b1270bd84117e57c74877f06459'), + ('\x82a58c097886c3f0a5ee4a4009a6ddd5fdb09134'), + ('\x82ab8338fbf3bfc2a302ab45c6b04e8ea2f6b6cb'), + ('\x82b23c53d7633d40e44e283ce8f6173556fd035f'), + ('\x82b902bae20f070408c2ea86b5751ccc89b1bbff'), + ('\x82b919c227e66469a87d3c3aeb8de0054fe53888'), + ('\x82b91cd9896758f0cf09948cc008989dcc93cd4e'), + ('\x82b9b381f900aa11e60d981813f1dfc17ccdfac0'), + ('\x82bef10c54f6ff12dceffd5cb58fb9fbaebcc215'), + ('\x82c42cb186a564197f95575ccd96ad35a9481300'), + ('\x82ce2574752c6b106f8ad57b437be4c455456f40'), + ('\x82cfcc2b1026bce0867d5785bc32b45c2eef1ade'), + ('\x82d324aa0f7665c26c939a4901d228baaa1cff09'), + ('\x82d8b14b4db341a04d01eb593841ef01955fd327'), + ('\x82dd0aa0470b902ea37ecea21402741f850e5e8e'), + ('\x82e474981333176c4856a723e5c5e52860a9ec35'), + ('\x82e63fc616b2d521bb89a235308947ba244007a5'), + ('\x82ece1ab8d6d533203b29034e64e29f35d09e7f0'), + ('\x82f4ed219e53090a254facbfa0eb3e55fb049b1e'), + ('\x82fea5c61ff855edc75e4a3de86a5c6446b533ec'), + ('\x83013330688cd4b2c96a3902f0e49cd4f4ae2be4'), + ('\x8307a9eea10b2907a602d2bc50b8a9125f9435d4'), + ('\x830858bde12a332b96134b38c3efbeb7d887b806'), + ('\x830880a8c959d8f2d27e39852a361c0cbacf6b90'), + ('\x830886ccd9c92d6fedf042b0ab4b71aa168e8b63'), + ('\x8310c3d92e9276fbfc993e4edc5cd8848dea420e'), + ('\x83111a0fc6f641558af285cd8de6f36d518e344a'), + ('\x83169257bbaa5ebbd78f093bc0c6b4265e7249c4'), + ('\x831b9226de591e5e377244f3cae758729151015f'), + ('\x83240e66a5a817cd4938851907982a64e0ad412c'), + ('\x83286c1a8036ccefc4e2bb80f660f1435182a270'), + ('\x83296cb0e8eeb0580cf10c694bcb892043e16c75'), + ('\x8329f90a02df265a85a6efc47927c2fd8bf3f60a'), + ('\x832dfeb212fead673de06a44268fce54a57f710a'), + ('\x8330aebac6e4be3939a5f57a621e20c1caa5a8e2'), + ('\x8330eb6352e758343aa67123b9d2006ab1d92d64'), + ('\x83360294b0213daed9244a5b9e4cf9646db97ad7'), + ('\x83374f24e8996a31d568a444bc77d450c0452fea'), + ('\x8339b01fef6908e523dab5b9dd7c80c5dcebabf2'), + ('\x833f00a7e609fa7dbc0d21a95a2c085a90be85a8'), + ('\x8340b95c1cb32a6f09ab027e79013d155cc09a33'), + ('\x8356bcabbd089b5368206e43a1426ee6829eabfd'), + ('\x83578e6d841b64855551f9981032ad4d3c5f2bfd'), + ('\x835ceb422c227c151cbb382e03ec3a5e91f91e77'), + ('\x8363064f1626f93b22215e45d9b5666d73a85146'), + ('\x83632851c7a3dd54be00ce479eb66748f565bf18'), + ('\x836419248a1ab4c5648a0d905f5edc04f9d99753'), + ('\x836ab636f31d766baebda544b21ae446020f1a99'), + ('\x836b97a58b7f04a74d20456d95665ff0f3e29847'), + ('\x836c20b15a9a783b94f444069d0aed0f8a5ea671'), + ('\x8373baa9987361b95492bd318ff216ce1ec50e7e'), + ('\x837415d74955373774beba5c5dae7dcba360e404'), + ('\x8380f8214fb137d8577f97a07dca054ad799d1aa'), + ('\x8389c6043511af54271d69a75656f8e2874880c3'), + ('\x839983bf987bcf2a1805812f1d71077586cee9cc'), + ('\x83a0f608520041cc43b281b28b50a0158df574b8'), + ('\x83a21b724c47127a95e725b5c6bdfa30fcc1a7be'), + ('\x83a224489ec7b119ae0ae70bc923aa1b9497c25f'), + ('\x83a34412818469830888f1b73f901d24b6623c23'), + ('\x83b11ac4e9164144e8cc87abd431218f806c5d0b'), + ('\x83b28b32cfb60abbc062c4f34a470a69e4fb3fd4'), + ('\x83ba2568b6d6e2e1a4eb3da38876ed9f27506488'), + ('\x83bce7e9df5579fbed8890565f6ae4b9635ac02c'), + ('\x83be83b88c21cbaa93ace4fdc886516af30973f1'), + ('\x83c1055b9b0389a24ad8a6d0a7d3a7e0e0777b4d'), + ('\x83c75a7e505d0180d08b7c752f6ef870baff98e7'), + ('\x83d986e216020929630fa04cb07a796a2aaf3455'), + ('\x83e68a5446229f02c33348b71508ed1367b25907'), + ('\x83e9e1e4ac269d4b12bc683aebe0a34dfd194b15'), + ('\x83ec283984f14ef9cccc771c14e786b674c9af5e'), + ('\x83f3ea0bf024bdb0a6aebfa57ffa9fd4efd65d9c'), + ('\x83f48d69d9bd772d2a293878c6bce34c73e13ac7'), + ('\x83f7ae99d9309811e689a5288cea29ac1afd6778'), + ('\x83f7d4b0cf9c26a60c2c8231fa9de71b028ba196'), + ('\x83f859918bc63ef722eb01b1d11e770becd88f89'), + ('\x83fe075e377a6ff6490a10a53947f4772e31e051'), + ('\x8403114f721bb7a68c7d3577381822d04b265a5d'), + ('\x84043d51ad75e2292f7190da05575be23670dd2b'), + ('\x8409b70ce97903cb1e1b844e96cd9fc5d361faa8'), + ('\x840a1836b4ab015dbe946ef9211cc020db16b885'), + ('\x840ddfabf19789ef25dee44f7e7d2b7d02e33b77'), + ('\x84133b1a7d9c6d74de7274e1a1d3dcc556db0059'), + ('\x84188623c3625034f168e6bf52abd4f9aedb6976'), + ('\x842ca826cb03590c11eb5e1f342ab9f6f6ca83a1'), + ('\x84332287fb6b02c358b1eb9a396832625cb5ba42'), + ('\x843335542b03757dd804a312477424a6f00d9255'), + ('\x84364a77b931cae2bd9fd3b28600fb010a5c33eb'), + ('\x843e1cf8d2c14caa84b6ba0c7f71bb2aa37a893c'), + ('\x843e3d5b9a52c705c34255b26177ba6d064630f5'), + ('\x843e7127f3e6a838755d1cdc2344527513f706ab'), + ('\x84431f025ba657dfb44e14bc312d7d27800e0c41'), + ('\x84433232590fa98203001e014552d3ad6b9e8ed3'), + ('\x8444fb307dd3b2145932afaa18a4c40315aa9bf9'), + ('\x84474920116ad4ce484357ea7223d39407201fca'), + ('\x845042699412b5c0b8d4e09418fd45c47018ef29'), + ('\x8451543b242b27b9a361831f16f3b3714877000f'), + ('\x84528124dca6cb20db9422a1791ce72a11ca6efc'), + ('\x845317703ca4af10b1cc69679a3ab3a6d1b47567'), + ('\x8456615aedcbaa29b1c30bed0b972c903ff13266'), + ('\x8462c861419a2ce0f71ec3fde21c22dd6d224b28'), + ('\x8465c8b20c5d1fee1e837916acec1e4d26a257e2'), + ('\x84693a972d6c8ae8beaa2619b745c75c3e493323'), + ('\x8472b6e77eab6f492c3ae38c52dc06142c0d1b31'), + ('\x847c4a24335e542fd6b95ae2f1a1742150411cae'), + ('\x84842ec5acdff41771ed505e47c0d80f42032743'), + ('\x8485adefadac236d9527a52a4c5968ba8b86ece4'), + ('\x84943ba5926072a01f109f4e1d9c8500dd7988ee'), + ('\x8495772af7b7eb2d1cfa8fb2b1272d84e7c1e76c'), + ('\x8495bc643adc462f124efa14d7e23da302fa0d5e'), + ('\x8495f79f4e0ccdba4bd43913eeb6dfb90560b6f8'), + ('\x849674ef28c4bf24537459e4993112cec13354bf'), + ('\x84978e1a0d36f325bb87d6a51a2aea4c16810a7e'), + ('\x849a8b9713ae3649fcdc8711a605b251e05aaa8d'), + ('\x849bbe66e3a7d227a81844a28c92a2dc51c55af4'), + ('\x849c58e65437868a0ac276cc0a690a96ade975bc'), + ('\x84a18da1387ab0787fb07b0edd353f0a04f6b7fc'), + ('\x84a28341e5c8cf14d13442dd5c08a9bad9e3d309'), + ('\x84a612c06be46b3102f7ec81f5cd64feafa342d8'), + ('\x84aa3b4b45806908b31a8617bb67ec49c62cced9'), + ('\x84aa9074e553d71a9a77b108452e32cd28d18df4'), + ('\x84ad2202a9b5dd2c045f65fa4a8b3fb825a1439f'), + ('\x84ad627706f5e261ceafb5e9972593168c52e475'), + ('\x84b0964ba7d8087ed40e99ff5fbc435dd8a36e75'), + ('\x84c1da9082300ef7feaaf7a8cd3a110e20c5b521'), + ('\x84ce5479cd2a0138e4905fc85f145eb19f1cc375'), + ('\x84d10be45885c5d9396f0ea9a141c289d78a53b4'), + ('\x84d82326a837fc9b8672937bb55ef5fd99e669f2'), + ('\x84e580b7b0abf31190f19a9814765dd96ebfc500'), + ('\x84f2ebf38e999f5939b19e8c4acf7a4698087167'), + ('\x84f754568a0bf248cddee794e10fdee105ff80f7'), + ('\x84fc9bb6f24147b16bbfd9b167ff527418d11d94'), + ('\x85030fa26ecb78112a56d90a73dcf92d1eb5ac68'), + ('\x8503b87b49d92de14e97ef8e8880120cd2873d31'), + ('\x850780e551dcb357283de3fa5ec83ca46c2235f6'), + ('\x850d078cc6ba6c5bf432cb90dcf27a940da32174'), + ('\x8512d2dbd5d308fb418e3889015b76bba3386077'), + ('\x8513a1c562e689ada3a06d7730a6ad013f18683b'), + ('\x8514c63c0ae4dd17d00a7267481005357ede39f2'), + ('\x8516daff3c7ea7536103742ad71763d07c097072'), + ('\x8518e58c0bc1ae55b867c874c8fc251e8f4c7e5d'), + ('\x851c574b6be23d39637d64306171667ed074d0dc'), + ('\x851fd2cfd94fb2ff1d1690bed05a05059b30aeb1'), + ('\x85202e37fbd285d819e2ba996c6fac25c67e6bbf'), + ('\x85296818bfbf9b7bfdf672a22b8cceacdef1de16'), + ('\x852ae437beae977c9b153c68555aaf2859e088a3'), + ('\x8537307691275bdf5dd2c646e3775649643ccecf'), + ('\x85384cea7dde4cbc67c23e993ee965b9e947a0b0'), + ('\x8538530202eb2314a4b985c77b0678adc008c055'), + ('\x8538c2746a8ae43ae27c89f18f4ee8355174232e'), + ('\x8539e9a4c573aeca1e85bfe9b52bc2a1e7e6b9ce'), + ('\x853ab5e0357fc31f6cac9bb8b59c09cdc0144557'), + ('\x853f882ca7db2e3783d96a1f2c66cbe73fbb7308'), + ('\x85480a104504c1e3f61f54adcfead2717f9d522e'), + ('\x854e4ea6b86ca612969ed7634434563c32801124'), + ('\x8550abf081525998f5785486064cdcc7f550cda6'), + ('\x8553ad0f81a1d3996d0326bc0bc869c2ee6ae249'), + ('\x855ec476fc7a6ffe14b66d1ee24b73ac9b6630da'), + ('\x856093b8e22215f07067e2519851ddd272cf5705'), + ('\x856120100f5025021fd197f22b93dd8e4ad170e4'), + ('\x8564ee1c06f0e0831f8b9591d89d82939de040a3'), + ('\x85665dc83d05eb36c4e1d906bf2e1a5810b119ea'), + ('\x8567a9a787a823d18a05d613370a2516873c2b75'), + ('\x85685662077bf6648bba2f9ca2d1991156a2025a'), + ('\x8570badd6a044f67e28ef7f5b0cd855d154b5bdc'), + ('\x8571eb8230db6de68eeae01c6278adc41794ae44'), + ('\x8572a028de5e1cc2666a28aa32c3f6712886d57b'), + ('\x857c28d166f23610dd14b8a33375619370525052'), + ('\x857fe88b107e19c8839053304301bacb80e03ad9'), + ('\x8585050bf641dd1390cf416ce71b7e18797e1d93'), + ('\x8588b93652a9c73b2ca06f583b15ecd7920c1531'), + ('\x8588f1f025a76e742be46a2ed6824538d764c376'), + ('\x858b2a2220082c021dfebd4edd66bb0a483d5007'), + ('\x85932464d1c5bd267b06e3b832459533fb42a540'), + ('\x8599254322ae8bbe9d5249c47e9408c4b9c6c171'), + ('\x85a4098f875e08b2343a0fe945a6dd8ce2120be0'), + ('\x85a7ec2936cf9b5c8c2ae6b2fbcfaca79f6fd26b'), + ('\x85ac2513289fbe0fa1a5eb3bf72fa30fe995c639'), + ('\x85af96cd595ef1498450bc8ff0ed782abd169ca9'), + ('\x85b04f67428ea5cf687504546dc7d77026a6c0cb'), + ('\x85b3a52beaa915c9deaa16c6aa1641476870635b'), + ('\x85b5a18a8bb732f0c39fdba88c6d02057c40b1a6'), + ('\x85b85b1b944d61d011a23a62df2b4a09ce11001f'), + ('\x85b8b9fc65c7f005b622bf0dc4bce502b1f7932b'), + ('\x85bf1de2c65f38ed8af49845f48ecd3d49a8e432'), + ('\x85c123143a1b6f3ff689e0a818524b99986464bc'), + ('\x85c8bfae411e8b6463d173b22e9fa66a3289bb8a'), + ('\x85d1349b2b84518b4e82d2e315adbdaf5cd3cb77'), + ('\x85d2a34a367ab488c85c64a107473120ad29d544'), + ('\x85d45481cc250de3c17866beeaa142d4cf8f9581'), + ('\x85d88e53a57584fdb233d8f2b3ffe5fef1eeaef1'), + ('\x85defa894c091c9ed4d9a950b4e3fe4e062a77d8'), + ('\x85e0aa4e55cb9dae4f819b2d14c9ba25fc28e5e5'), + ('\x85e274ac4c0bf14c5713d370364529209f0850b8'), + ('\x85e8d6fe3fa662559369625163991c1998a37b98'), + ('\x85e9ae820ef80e960aefb221d5b0e460a3e90b38'), + ('\x85f29eace44a572394ff3db982a6ab2f6e315043'), + ('\x85f3bb548fd05bff387f568bc3a500f5c5fd1eeb'), + ('\x85f9b1efed4447737c0c4cd0128335858de35f17'), + ('\x860069e56e449552ab0dad908293efe0992875d1'), + ('\x86010127811e5e652715896a60482e2035f80646'), + ('\x8601daf81bcf456548485aacf7e4ba1a1d86808d'), + ('\x8616586752c1c049140841febd7cbbd8adb4638d'), + ('\x861ab8128d91795f218f9104e2b74252fc5ad54b'), + ('\x861b528777b905484583344f7c0363538ef8b5c9'), + ('\x861c27e177de37027a01d7866810e9c826887ed1'), + ('\x861d63adb6dee256c5d63879c323d27b5b0b047b'), + ('\x8621f5982c1af09fe32b383fe77fc05135626478'), + ('\x8622cec857032d63e68bfd977d239c8aa5e5160b'), + ('\x862fbcd41103ab0c721cdcf46f52131c89dfbe03'), + ('\x86316d7aa237360e48f627995aed0aee0b08e0b6'), + ('\x863208892533c79926b08c44968142d89354804c'), + ('\x86354bed04930be27376088501e833fd6dfd2d97'), + ('\x8635c0047b9524eb88bc15fd7f9043f9267b38e4'), + ('\x863a375891beaf25cb859e3c9e94adfecf22001e'), + ('\x863a544d565d40a0dc60c303827de65f0482dd83'), + ('\x863c0dccfcf1ee95e1b975f77800b4961fb80bd6'), + ('\x863c41c56aecdd196611a111ff27134cbb6c46f9'), + ('\x86417e73e06585a3f8c05f56c6094500d4d8af79'), + ('\x8643ca07ed4223f6caf771f395164df7b144c43e'), + ('\x8646c74bd0270ce1fd4bf8d1f7490b13f1d34ac0'), + ('\x865a5d164719b1c6b093bdf51788ef6dd4d3e872'), + ('\x8664770033c9a2aea7ba6916bc716653d8f7f89a'), + ('\x866a9d5a22c0c3bbc1f0e700c8e0358c6897719f'), + ('\x866fa9c1791f253f01d6c7f2d17b2f05bad0f481'), + ('\x86741b8bea23b8d5553dcdd66f0996726afea04e'), + ('\x86780bc61e19b8e548288f7e9c3372f1d80a1f42'), + ('\x86787c74e17362cb00be9017e9f67474afe70738'), + ('\x867fd18140f8ee6950875d923d8d25b9d815c693'), + ('\x8682d94623e575a4ecc9586a35fc909dff37fb2c'), + ('\x8685ecebd2c5aca577a251525e163abfbfa6c7fc'), + ('\x86895d335e7141e6212253a46689d088abb5af48'), + ('\x868ad05d2baac38518a7d144bf794dd0d925910d'), + ('\x868ee7d92a3ad3878794520c7e61f2ba524057d6'), + ('\x8692c752e9c0b9254c0aaf34ca653b4bd52a7860'), + ('\x869a91eb1710afffc5775e0f1bbfb7cb54afde99'), + ('\x869db76ee73bd3b2c70cab17da558200c657407f'), + ('\x869f7ebf5f88249462c000f1790a78948ec069c2'), + ('\x869fda5a4f111beb109f8ba120a0d8c817ba5f07'), + ('\x86a818f148616a1b6878283ee4a1173d748d3503'), + ('\x86a8b89e1261ff9e305a2c0099a1664b7ae1872c'), + ('\x86ab48f7250206514a0832e376ab590545500bae'), + ('\x86afcaf50b3813f3408a3c9918f6f6648c7d5e7e'), + ('\x86b3c5b922b799aff98e04b7f173f321b7628ea6'), + ('\x86bdc1f865bb2b9a6693f0843bfce6ff224469e4'), + ('\x86bdf151a7754b0c45f1017d21d25fada765bbf0'), + ('\x86beae3554894e50d123b0a1732e5152386f14b8'), + ('\x86c1c546306ed404a6b801817ee95be116840658'), + ('\x86c9de59c1d90b38ad5d0ea21c9ab33cbc082436'), + ('\x86cb7e89baf31a6fd64fa5958fb639e3a7a506b0'), + ('\x86cfa15b81018c74ef81c8c5d47c618d73c5fc44'), + ('\x86d4660da358453804ec676bf3c69ec2e66cec7d'), + ('\x86d5367f38b9322ee51beceefd0827b5a837d489'), + ('\x86d8b4c9d3621e85ba464c7f1a71a28eb3609589'), + ('\x86d9c0dfdbd97c4b5a4277ca9553ee988061ae48'), + ('\x86dce2b4211a259ac30ad83dfb706ce016f41881'), + ('\x86e6305a846ba9f173dac75a9c56e67a960cf329'), + ('\x86e7bfdf3575a3c47be40fee56719a3dff7a4e96'), + ('\x86e846a6bee27c1ebda433905913bad7858c2700'), + ('\x86ee484518e827431f0299c89eb818032f07c6ee'), + ('\x8702c97bd768311bdac45f1f6dc33fa4563a5ca7'), + ('\x870333c222b839519772461763311f4543fbf8e8'), + ('\x870ab067fa78278f20a1cdaefa14fd219a748ad7'), + ('\x8716f80644eedf4cbcbfc3be7a177abcd74740de'), + ('\x8717ccaf2be74903c5ebe6eff1a5f4e4b882f86a'), + ('\x871c074920a996e1b10bba35d166debf3c0478c9'), + ('\x871e0f444024465c140446d72fa5bd4bb2a3d9bc'), + ('\x87201733f32b19010739e26b317e1b09007c3437'), + ('\x8721013ab9eda616a3aaa8ecfb87a4491dccde92'), + ('\x8721ad8c31238bc8a448b6cf25fc3ed962684b42'), + ('\x8725d63d48cd353106304060003ececc97fc08e2'), + ('\x8729fa6caee6345b434c77499b0bb96cda4b3129'), + ('\x872d89765095f71a8b62a2d60b21ed1a39232394'), + ('\x87313e3a17ed4a14695d53e4e3ce01cdb04a543a'), + ('\x873657cd8beed24f7cf0cf49a82516c747ea80f8'), + ('\x8742c73a5515b762635dcfc13eb6c3f8073c9e7f'), + ('\x8744695c2cae0da63f8a1843810d345e4dbcd2e3'), + ('\x87564aa77de98b8b7fdfc91c6d1289d5a65c3da9'), + ('\x87585f5396399a92dc71b5f44a1b9b440e5d2b19'), + ('\x876151565f27abf3f3ec0535597a31a94585fd3e'), + ('\x876537d3fe442e040692791de98259041138c2dd'), + ('\x8770732928cb20d8aeafee32bec9c5de89d51238'), + ('\x8777a0fb0d77a0e2ae31590d1cba2abe11a7457b'), + ('\x877ac9f9b5918fc916798898a50ba3a901c8b91a'), + ('\x877c3763433c9af142a7bf21d8d2ae593ca6198c'), + ('\x877dad12a4a8ced17a159b722d2f3767426917e3'), + ('\x8785038815c30918addec5cdd41ed2194668192d'), + ('\x87983419893a8952c3f286dc56d37fb94e320da0'), + ('\x8798b8964317e4620bfca94a0c320bca521dd832'), + ('\x879cf778d4d095432f965058564a69ec6c6d3be5'), + ('\x87a1c168602470ba91cad5cc243dcfd09ecb67ed'), + ('\x87a1f88f3671f7f4429064fbae741456ca25fbc2'), + ('\x87a2a4cea9cfc7c2c691ecbc3ec4302d9243c3b2'), + ('\x87a429b600ec5dc614a58682bc0ed7bd94a340b7'), + ('\x87a603ba2975a924f197b9b4b6877342923a17e2'), + ('\x87a7b5a650bdec148a185923b379278425f682f2'), + ('\x87a7bb52d5eb3b7cf05380692a430bfde1f5cfff'), + ('\x87ad4e4cd13a46049a07fb0cde53e51920ba0639'), + ('\x87ad9d0e33c19309a15bd6fa5583f8e5fbbe9278'), + ('\x87adfe88ed3ef4b7e7aad7f3e2b8741058a86a61'), + ('\x87b14f8014318f27af1181bd0e72e19365329e5f'), + ('\x87b2120372dedde3f971b5145a6c6c068ff9f402'), + ('\x87b395adde5f5f6b08ba9a21374d4adb15fc5e1b'), + ('\x87b3f538721bfae0f9baea1490ca7129d68d6922'), + ('\x87c46a1812c1243fc00057db880d60ecbfa93d2c'), + ('\x87ccf7ff524642abc10706ac6b263bed22493dbd'), + ('\x87d28e8e5ef72705f827c03bc1b5e81c0cb09ce6'), + ('\x87d2e0af4c4fa45ef518c0c522726100ccbecbba'), + ('\x87d40db5769e836d1be928d8fae60d5be9670840'), + ('\x87d4f0f38555226334c3d4b7e3c752e4c1f22a2d'), + ('\x87d8d25d9103175f7ac3f1e7b0ea3a018282e5e8'), + ('\x87e124e441891879b61281fb3d6fcdb356fdaee1'), + ('\x87e4ba4a4e52c7caf3b5694fda9a4ef0869d7d58'), + ('\x87e7bd494c9e5dde3444ad16884e6701f9184d88'), + ('\x87e96a3a7904ccf1507e411dfb2442ef91ca49d0'), + ('\x87ee4b09046233d6ffe62065e5b35b511d51cea0'), + ('\x87f11534ac327bcf517ac64646576b39cdf73d20'), + ('\x87f6207d2fa442237d2673ee0aad3761ea8f8275'), + ('\x87f9d0342127eaef5186b0457646a6b75b5c6189'), + ('\x8803e3ec471a99b0725583ee69258fe20608110a'), + ('\x8809349048bdda4d36999db0a008b8b4b9b3d13d'), + ('\x880a27d9660f095bbc455f8c94fde5c282dfb44a'), + ('\x880b19b5c3e265f0ff0a20365aa1da7e604ed6b6'), + ('\x881791a2a76042cdc8944c8334bec85673e4cbd0'), + ('\x881a64eb6c6e47070af0e0fb9cb63caabf19ec21'), + ('\x881a8fa2a787f31724586bebfb597e461e759a51'), + ('\x881c27df59ddad7631046da6f3ee1b4cd2041264'), + ('\x881d3f95a46011a4ea091d103f0740a9580bb1e6'), + ('\x88229c5e52b4fb1c7de15829e70ba78e1e5f15c2'), + ('\x883f564a81ed3d6c1af21fbbfcca01f6e6ca8161'), + ('\x88476e16835dcf50def54154c1864f7e92899b45'), + ('\x884b4aa0f39242cde252980e6b22a8978eb51941'), + ('\x8862c91fd66da0bbddc2be4c5a8a77fee6666008'), + ('\x886522f97af24c2596a3da021d83aa3bfb3738bb'), + ('\x8865c960290d4e5210ba2e2dead5e41903c06b2f'), + ('\x8865f22b493075eafe69d53c2b1d734e1a350ef9'), + ('\x8869666f245c6f474b98024327df7082e4c868b8'), + ('\x886bf7264d8dd0e22e13e9bcf419c1fa6b1448cc'), + ('\x886c32a289c96222834e25aa402beb7f7552d7d9'), + ('\x88757c795bfe067c0d1e0c72a80a5f96c428e90f'), + ('\x88822d274b0c1133903d7e91c04525d9033145c8'), + ('\x8885bac518ac6a18d61d71b9867e09e19ae6a73d'), + ('\x8887278400f6f657396ff6fd8d9f2f3dd2d70783'), + ('\x8888fe5ed56f4f909c8dea6eaf4ea81d21004bcd'), + ('\x8893da011cbb93ae2cad05b0fcf17cdafa6d5eed'), + ('\x889b76222acf7f71b624c558cf32ae7f739f416d'), + ('\x889eb57d03db85f20a57c0537325ae2cf7fc2011'), + ('\x88a034d3ddc21209d4b62cbe8b538648f2c42aaf'), + ('\x88ab7fd2c6215d0f4cb1abc29b55d0a595441d9b'), + ('\x88adfb798199c463d6191657e1312aa5a518799d'), + ('\x88b47c9b572a60351bd804afcac73f57da3d123f'), + ('\x88b5d1f805ee4dd32a4f61875d596d0576b25caa'), + ('\x88bc61b6fae979d5e632a9b8b396b51ca27e8491'), + ('\x88bf1b692b2e7f74042db83e6959827af7b98acb'), + ('\x88c098e7d9870dceca9529e1ea5a02e0b3aefb6a'), + ('\x88d198e2036676a46c39628d24014d0de87be410'), + ('\x88d32c865a2281f7a891c5763f1d468ef8036a84'), + ('\x88de9bf22a380b33a3d87a607986527dd2adf8a7'), + ('\x88e1d4334d562c6097ed01f3ec8108010db0405d'), + ('\x88e206c1a51db7ed35034a18186044717e670a6f'), + ('\x88e478fa2f366365eb46f580b4c4ae9dbbf72fe2'), + ('\x88e68a93c04ceb3acacccf96f27f8f412ee54edc'), + ('\x88e98f06181a34d48f4af6e82ff77929409144a1'), + ('\x88ec50ad998e9b1de2f6d721b6b719d212128151'), + ('\x88ed87bdfe80410966793f0c9f00a02ae786e492'), + ('\x88ee86056153dfeec85fb121913090ee69775c07'), + ('\x88f41e6dfb249762f3823c483a4163c8f9298290'), + ('\x88f81b2364cac3be52a97923053d0310944f6d62'), + ('\x890492b5088bb0d90e10797b1e703a9de0870025'), + ('\x8907ed36c5355c091d7a2125f68a81c5eb9cbf7a'), + ('\x890bd51305a6cfce560bc3a7588d1b75a29fd609'), + ('\x890ff07c85bbba6ad593e60707464379b1731689'), + ('\x891d94a53fc1991f784b063a55b0498b88b0f2b4'), + ('\x891f4286f6eb0a82f7b19808271886b82f83787d'), + ('\x8932a2f958c719f1101b7cf9770522bcc3be61c5'), + ('\x8934deeeb078ba3dc1b1d28b98ce54202e8543f4'), + ('\x8939a5767152a553b774544593ce1c6cb9b1197d'), + ('\x893b3d2cc6f8b98b24b41bc28e2fe61880f6663b'), + ('\x893cd85407371ce3c1b5e7898121d0fea253f13e'), + ('\x8940466b8ea517c8533d0cf7de9271dbb7a4578d'), + ('\x894874019b582538a5e55ffd012c243063b41ae6'), + ('\x894ad1c503f8e23f1c8c43b885d947e61f0ab7de'), + ('\x894ef60d757b7b922d32c398489618c1154a356f'), + ('\x89500041913aa88e3290e894a319764d07665c59'), + ('\x89518d3ab6e65d9d06877dc3f96950fd8bccadf1'), + ('\x89590ffabb6417cb1bdc36a2ede6ed4559699393'), + ('\x8965ba1c6742dbb862216d2003fd4444e2e9cbb2'), + ('\x8965fd794322bdef40c7e318cb729614a04718b9'), + ('\x8968c4ab6d9ef35a32d42eb62286487b2876cc08'), + ('\x8971035e84d0ab587657183bb0004db8cc312060'), + ('\x8972a31653109a18c94d215c4583d92b048a705c'), + ('\x8973a3c3ce7c0f00da7278ff8c78a47f0f35c19d'), + ('\x89755b269b74bd6ad2150df95605ffb7cd50b340'), + ('\x897df68ddfed06a6c61d4f59b0fd397187c1971c'), + ('\x89845e1d858e9aef192d4f9688492d4b3a7f2d65'), + ('\x899064eff40fc0d6e40c40fc89ea128ae7ff317e'), + ('\x89907233d3d42ff00ff13dc2802c8f266d9f8dde'), + ('\x899805322f9af9d4de53892db2202d0637ab3bed'), + ('\x899c21d8a6831426e28fdcfffde34f51a29c7ebb'), + ('\x89a60d1b1dfb771810be426fd22510a765bb6f7b'), + ('\x89a64bcaeb9042676aa7777e7140a7adaa3d55a7'), + ('\x89a7d5fd1ab3e0a115792a5fc546eff0ef506885'), + ('\x89aa59d106630a894ac67795fbf6f2a4a6f086ae'), + ('\x89aae6b6afc9a15381aec165494e81e99f93499f'), + ('\x89ac8db610d9df6c52601ce24c460236323f8cfe'), + ('\x89b4fb0012e8abbd482d0038d15b85ba26cad3a5'), + ('\x89b6ab269ea24999b726ba3ffa18f3d6e0d2e229'), + ('\x89ba8ba96168fb420ac556bc3f09a9a1fa1649f0'), + ('\x89bf072a471ab3d4dc95292c24e6ca21e933cbec'), + ('\x89c6903444ab460fb59a52448a245edda06c2f4d'), + ('\x89c995ac9b080b05d669c9af23acb672ac49156c'), + ('\x89d594e20b696f657d90a382fcf5535cd19b3547'), + ('\x89e229e3030ad82b3f29bdffd32aa2e9856f9891'), + ('\x89f262a6f2bf20cdcf3f8ebfc3c477b83c7438d0'), + ('\x89f497324def02c599bef58d0f47cbf396b1ab8a'), + ('\x89fcda204c5ce1e58e95528d30f047208e348dfd'), + ('\x8a036944fda57b9c1913c36d1ae12eb20ba3c457'), + ('\x8a065356f3286d3c2e0dd7484cb7309baea4e030'), + ('\x8a0b2ffa783b8dcb598a4fdd0f9efab526a0442f'), + ('\x8a105057dab21d091fc75f304ea97dee17a648b0'), + ('\x8a10e32bd1b2f9ed5225c63d3e4f4cdf7db59cc2'), + ('\x8a14a1aab961d8dd73cce8ad0b8133d7f245ee8f'), + ('\x8a18d44666b21276285503817df392d072081406'), + ('\x8a1bf6a3a1b048d5b8e560dc34c9efb657c2d2e0'), + ('\x8a22dc920ac4f76ef9a9331a0a6cfec450a4184e'), + ('\x8a23c4c2a58a69ae6bd457ff140cf68d3e011c58'), + ('\x8a24973d5dc12959c46d8cb10d7457af7bb0c6b4'), + ('\x8a250af6233c60fa5989f439f7a6cb22ab60c8d7'), + ('\x8a2c4fc79e2bb48f98234206d923ccbccdb96d1e'), + ('\x8a2c610459752773948c230abea133a84873df77'), + ('\x8a360eab36aa262a1ae64e7fd35fcc510a7c1335'), + ('\x8a39ced1800eceb630a19060a2e0c1c4b37c3c14'), + ('\x8a44e88d2196e68f729904cb492430e89ba49c71'), + ('\x8a47994bc3e8b2406e7eda03f907c0b03dd635aa'), + ('\x8a4a7436c1c6da4ef34eef76ca8532c31742f596'), + ('\x8a4f301ce0ec66fb35d8ffcf17d279dcc26448ba'), + ('\x8a56b56e484b1a722fd41728efb9fa7467c138f7'), + ('\x8a61507701d6dc277f9d1b91f7611d52a0a2db52'), + ('\x8a61c2d0d7eb8bb4b19c019f4b19419ddf1fa337'), + ('\x8a6689722ae65b89b0db2e663622e111b443027b'), + ('\x8a6cde2ccb9f2c33264b766765eb73fbcf3e852c'), + ('\x8a73e3baafd94571c7da0c144ffac5ac1db62c89'), + ('\x8a746d06cbdd59d74547d805844ea03f65bdc7c1'), + ('\x8a7c662f8ac5ec3d44232d9438f22c6ffb768f12'), + ('\x8a8b55ee3c52f68b9249003fd316cc7a5074e832'), + ('\x8a9247d27b7dea3cce8a49a6dc8350e4c64d1f00'), + ('\x8a9b0c2bc8d8fa6a473d0c0ef50f253b7ac4b7cb'), + ('\x8a9b1d918e13a1d7cf102d34e96a6ded62790f88'), + ('\x8a9c04ca5e2f71dec6cd1f4fd5111da80d934744'), + ('\x8a9c1b5d06b877b572e1bae6e7b21f2f6be79ef8'), + ('\x8aa1f48d191d28237aafe7e604d5d6a234dd24ad'), + ('\x8aa3e0c76d478069a7253eca5ae9a5daf988d567'), + ('\x8aa83e00a98a66e6d191e9163670947b3384f7fb'), + ('\x8aa9b870c09c7b17d8a352b3d6422d0da1753177'), + ('\x8aac1a70fb74f2b7771e7bd935d81ba349caa7c0'), + ('\x8ab14b307e76629dac1fa2f303940eec0e018378'), + ('\x8abee79b4190eda097699738146ed799801b7554'), + ('\x8ac07609a20126e310a4ac27082e528915841130'), + ('\x8ac46e3f8602d9f8696f95b85de1211471ec9f55'), + ('\x8acd4d9c6c603c8f6c81503c3512fe000cf5a7a7'), + ('\x8ae5d5c2b9c7d382df28663e968730fd3d509134'), + ('\x8aee1d629c3b8a7452181626cd1013113fa3cbc0'), + ('\x8af32c4a750b4212a54aca7a3a10f4d3660e2daa'), + ('\x8af3630d29af944e746ddb3225e3907c984304c8'), + ('\x8af6a853d1c52baa3b392e1af84829288abbb31c'), + ('\x8b02e28d3d54217543386fd2c229c592b32e83fa'), + ('\x8b0a9f9f1da9f2c408b64132cce8557b973d526e'), + ('\x8b137891791fe96927ad78e64b0aad7bded08bdc'), + ('\x8b14bf668faf7556d36d1595b5a9c414f18bffd4'), + ('\x8b196bd3a04e266ef35160e4ec11db90e86f8fe6'), + ('\x8b1a32437f1d9d8f9de11d0192bc811713d340cd'), + ('\x8b1c26637127ffacdabdb380551d31353af55029'), + ('\x8b237fa908e789163472c9113ae332de966b0d2f'), + ('\x8b2e579f8a9bde8732331c35058a63ee67ce5ba1'), + ('\x8b33ee045deb234dc6e08dc78187a6efa1166e78'), + ('\x8b38b6c4f6d30ac62b45def7995bd01955489aa8'), + ('\x8b4572a2cfc60158fedb9a06f213e17a64834e31'), + ('\x8b48e0ab354e443fa36e82f3d1eee669c61d59cb'), + ('\x8b4933afadec6adb60dd70b5fcbc04232c58874d'), + ('\x8b4e866813fcf7c8385b465a214674aee174e4e8'), + ('\x8b500f669ab89015e4b29a32647f56b7d5925092'), + ('\x8b52bc79c2c5e60dfa8d0d56bf610d76f795bca3'), + ('\x8b5eba22165348426e5cec66b189f995ab8bfde0'), + ('\x8b675886850b87a4044b305f0c5a5a197cc13663'), + ('\x8b6c6477d859744d404c37165bafad6e8aace13a'), + ('\x8b6d30feb242a281d50a1388972b454d543fd318'), + ('\x8b6f82dc2e4668f56dc9dc5579bbf283a93660ea'), + ('\x8b714528d82d63115c1cb025debdc6da2157e87e'), + ('\x8b7c596deff44eff6c3d470e27a8ce8020de7ca9'), + ('\x8b80e28d6627886c2dd37806ab1e121b6b9175a6'), + ('\x8b8daa80586ed7fede344f0328f7b23b07c65ccd'), + ('\x8b8ec5abd5346075021f8c29f517846d5b168885'), + ('\x8b9452d54b89208fe5fa22e7530a77ea3c548efd'), + ('\x8b97b1881243de418c9e6ba1e23715dd848c88c9'), + ('\x8b9de47d84436e2d16f4171d21d7066223d321a3'), + ('\x8ba2c2d6f649570ba9d4521289dc1f14ea796809'), + ('\x8ba70cd8f77f8b569b95490a69bb27c0d0d92740'), + ('\x8ba73a20a58257812bccc9894d2b73573e1deeea'), + ('\x8ba8e7c8bcace74a505ea8beb3e3a6a48c7294ca'), + ('\x8bae7153a6a5a16addac41c0d7fe3b1f2d187613'), + ('\x8bb1f8aae4ba8445409fcd6a2fea483076950937'), + ('\x8bb383311d0632dfaa4b7db4738a279ecd5fe35a'), + ('\x8bb4ee80c96fc3fbbae0b642d04914ede4ae1c98'), + ('\x8bb7cad2c23148eec369374323b1e37ead5a9c35'), + ('\x8bba6e929673fa9f9afe930ee9e62b6104d59424'), + ('\x8bba98f32bca10941a574cdaba83ac89d1acc5d7'), + ('\x8bc5bc8a749245ca9cff5ea29f73c53e209697d9'), + ('\x8bc7130448861de19e80a58495215d0b95bdca24'), + ('\x8bc9098a295b510230bb8ff7715752dd6caba90a'), + ('\x8bccf57c8045f1d2b855be6ef2fa7ee519c38955'), + ('\x8bcf6bec7084206f32dc3483bea9df9032f94375'), + ('\x8bcf9be88c265dfe3b8c0282cdb5bc23da99fa25'), + ('\x8bd8447b4b2c69b35b56f69ef6554405894aa437'), + ('\x8bda6d05dca739214db6993c3ca2d29e2bf99d96'), + ('\x8be042232cba231647ae1d1fcffc94c198dfdcd9'), + ('\x8be10068c00837004b5aebad4ad8ffcd62726d00'), + ('\x8be7834cd4bc8a753046ecd550517d35da7cbb83'), + ('\x8bedc913dc07870c234335c3c9f8a348a5c6559f'), + ('\x8bf302dbf0b50a2df7686f48851698d514d47502'), + ('\x8bfd5cff419f6d1a8c072ade34350e608ef350a0'), + ('\x8bfddc4f4417dba74ee85bcf64651f8f79c5fa27'), + ('\x8bfe0058598215646c8cf1f25ed50558a921505f'), + ('\x8bff41b3f67f389e4cd737c583d29b7370401836'), + ('\x8c0486efe897f05feca91d8b9adc6461cd1a7a01'), + ('\x8c0746a6250cb88fd22b3b41f5b58771bf7ddcac'), + ('\x8c0a0823744121c1f0478dce2c531205a87f2b2f'), + ('\x8c134889c1688d365722eef494aad22757f06e56'), + ('\x8c13a169fa24edc18873809ad8bcd3b0f3e79f35'), + ('\x8c154816b0b884bf5e789fbda62f1ccf35523d0c'), + ('\x8c1943985f61da1b75a5c6fbc3c353e95e30ebcd'), + ('\x8c1c4f8002ef6f144a73f6334c5113195ec349c4'), + ('\x8c1e2585ed2bba5c00b5abab4fab9f60e9860f8a'), + ('\x8c1ece2c34b6d6e55e23d8358f3b1ec1ec13a76f'), + ('\x8c20fc62e973483d1989b373870c118859aacf43'), + ('\x8c2252c44d4d00fdbfd8448332f66c5ea190e4fb'), + ('\x8c268b279cd72b045c43425af4af471c186c2710'), + ('\x8c361879f8227ea5e8fcf35e752df3aad49b3186'), + ('\x8c402bfafc6f731b6639c4cd4031d5b4f31b5ec5'), + ('\x8c415bce4e0eab7c79f378306e2250d4588d044c'), + ('\x8c4217651e3412b461e99ecb4cc81de1fb849e7d'), + ('\x8c4c15d88afd016a601cff5d0bdbf726ff571b95'), + ('\x8c4ce3c9973d4aaaa9bfdf05eb1a7ba2c477928f'), + ('\x8c4df88eecc740257e9ed5c826e44a89951dfcd0'), + ('\x8c59c61128f8ea90e98b9c8c3513f493ab5f9d99'), + ('\x8c5c33dcd50f57591bbaec16822e2a91b542ab1b'), + ('\x8c5deed69ac26b015c06670b275b217497328b14'), + ('\x8c614ffeb589f6a7c30f1a2043eea7176518bfdc'), + ('\x8c6d6209bf22218e1974ad2c5d9f403e000ebeed'), + ('\x8c70b43619548d3e355e536d748c0421d366b066'), + ('\x8c7148d676fb21376e3c6fec48d3b9b6f20451f1'), + ('\x8c7281a3271a89c90e0f5a93d33628cd73bf8106'), + ('\x8c72c0b5a63bbb241025f994be13ec19cf7722f8'), + ('\x8c7823cdabd78b5b49cac3e01e42fdeaa14529d9'), + ('\x8c7cfa8e24a3cefd6e154e25de622fd67b042345'), + ('\x8c83dcd279737f588e25f67466578726b1d0fd21'), + ('\x8c9ab9713190cdfb0b52b7ba9888aa6037da3bf1'), + ('\x8c9f6ab41c170c9be9c5ea94b19af91e8b775703'), + ('\x8ca14df79de8b46609eaa01ec6f0aecfcf4463dc'), + ('\x8cabb6262e6b7ce7ec8f6734ad5c870c4ff496de'), + ('\x8cad4e32f33a8781af2debeff6bd821a09561058'), + ('\x8cb155db0f500fc57c0c36995e940767b8b07821'), + ('\x8cb3cd87a06c3bf9968597c6c12a990b1ac32c65'), + ('\x8cb8664be880460e847560ad09a522e7f3504f5a'), + ('\x8cb8d202130b3e4c54f5d59832e9dae73faedd15'), + ('\x8cb9edb7db43a14a3e75de5ae3a51a77ac4c0140'), + ('\x8cbd108f38206d02fd04ee623c683f48bc0dac7e'), + ('\x8cbd437506ec88960d7eaf5a08756f25f692ef90'), + ('\x8cbdd29ae99fb9706f3ce09e3075192073d2f556'), + ('\x8cbfc5dd3f6dbd08f2bd53e7dfbb0d0cdcb52432'), + ('\x8cc243f694c48e933d93f03174dc33f82fb8676c'), + ('\x8cc94ac033fe6695a3a2c89c1fef48bea786e021'), + ('\x8ccab1824b15fbf56c4caea8b271313894f80bb7'), + ('\x8ce0dca749bc65332aa4b744d78abdd6055336fb'), + ('\x8ce0f13d308816ef7cd76d7061e92a92acacfbce'), + ('\x8ce4b5662a25daa2a22264c8b82c1cbd95e0e8b8'), + ('\x8cebdf33f4e78c8213878adc68ebc3cbcb6286f1'), + ('\x8cef6934b2bb5280352aab288a37ab8bf5bf79a7'), + ('\x8cf7433ef061fbcdd2167842afd99cd333671b79'), + ('\x8cf85ff415203f223a490561422d7bd787feaf67'), + ('\x8cfbae4fda43a114fbd868157a6bfcfc1653e29e'), + ('\x8cfd81ff4ba029929bdd342377104b3ba3aa56f1'), + ('\x8cffa16bb56e792fb1eecf1280276c3932d368a7'), + ('\x8d005b4427d462ed82992dde96de460ea981aa57'), + ('\x8d1b586398430785718a0acd199fdb5ea6d11cf8'), + ('\x8d1c6cd2e2e5b3aab026093dc2976d9233b63790'), + ('\x8d1cb8203b69f7cc734c426ee184190faabf5d02'), + ('\x8d1e0a5ac541a7e3d86034a29e6e1ebac80294f3'), + ('\x8d2140b17114c97fdbbeafc5ae5b72bb7d22ac24'), + ('\x8d22f7aeb0fd5d4688f155e5957fce00018d66f4'), + ('\x8d2b1c6478f81e0e2f65cb62debabbf236fb0304'), + ('\x8d311e69a4de7846365b208784e05bed9c8a8f79'), + ('\x8d399b58ec2c4649831828183e69c0fb647d6625'), + ('\x8d3da05657c13570b7bddbd9785fdaefe20e4d69'), + ('\x8d4a3107718b45d278fd3a66230748ec27f2e967'), + ('\x8d55eedf2a3188da4bd75f4852ef073042b9f05f'), + ('\x8d5602250f50e0b816c0baf6b7072c2f93630133'), + ('\x8d5ab6dff1f27cb94d006ad38c8e7772c049159e'), + ('\x8d5db03bf0387d0fb6b9f459970e575465471a07'), + ('\x8d62925dc098660fd29f5d2988a16edb654f79f3'), + ('\x8d62ad71d0509ff1fed5c3cb16d6b543625b6ad4'), + ('\x8d6dd77e436bc304347a4f44cccfdf1b60fb7b98'), + ('\x8d746a84c408d12ff5bc2e387f9e1383b50c4d76'), + ('\x8d74d8974eddfc1819f9753bce0fb119c7b10ebc'), + ('\x8d80e33616125d13b5a244f169d146e8f16c58df'), + ('\x8d81ab30a5db7d9a4c9c8920b31d2bdedd40bb09'), + ('\x8d8e607c6b0e39b57c3cad9e27033c926440762c'), + ('\x8d93280a2c2ba346e496540e0165d5ddefbc720e'), + ('\x8d9793c8b6540a74f0b1c5fcda0fa7b84580e206'), + ('\x8d9a2597aeae1cc55af9f3c19d65b2e9f2352f51'), + ('\x8d9a3e4e62b88c9b4205f8de3f1a1fe82bde6f2f'), + ('\x8da7364da849923c91e038b67910aee125663279'), + ('\x8dafe41ffc4693f05e941bcbf667469cf1266833'), + ('\x8dbc1075adf9d70e11319e3949ba5b44e003b4cb'), + ('\x8dc4d99e81e9f0d3b16d06ecf4a6a041b9cf8f3f'), + ('\x8dc6c9a8c632fcb647960d5c936fa98d6b81028a'), + ('\x8dc6f82a4f3a83581700b30aa82f0bde508c34c1'), + ('\x8dcbd340ab29453db61ff73d033004369c5a7f71'), + ('\x8dd1a2c039f37d0f9a4992c714bdcc0cce93e56b'), + ('\x8dd8a6951d14efdc1ed729939235999b26f96b5a'), + ('\x8ddbf489938bc8edb8a83b648b2a35f2120516dd'), + ('\x8dde9db56cf44fe992188b70611ad8e0b62fb7ad'), + ('\x8de117f20105cb2852f0145423317a5184dfd994'), + ('\x8de598d345ed0e9b05db6ed072224ab726413361'), + ('\x8de9bd9dae2463b24a6203693b980bf860ee405a'), + ('\x8deb4713a4bee91f232750beb0924647a0b1d3fa'), + ('\x8e0881bcda430b095100e50980108ea88d1d587e'), + ('\x8e0e6a87cfe0fc861e00492aa1d4ba8c78ae673f'), + ('\x8e10b73c26fe7595aab27f4e0ad5e6cb687dc819'), + ('\x8e162dcd6be1dc2296871c1c7dc5ae9b95cd1bef'), + ('\x8e1e444bdd6773830dba2ffa62c593c894c2d10e'), + ('\x8e1eabd54af4fb59cc414cbce40826fb491320b5'), + ('\x8e21d1ef2025b4ffc4fdac5895e1a1e91a7f46b0'), + ('\x8e26f2ef3f8cf16ee3f5d7d385acdbefb23a2c76'), + ('\x8e2ef9e529a09177e4565ff5f3405a9346e72c49'), + ('\x8e309b6c23ef89519a25743f57de13b11b555251'), + ('\x8e406d195b7a016415ed00d0b0d42a0dd8b914bd'), + ('\x8e4ca529ca808aeb95c648e9eee1709abdfdb757'), + ('\x8e5151cb704164650fe35ea5ee6ef7705b1da4a8'), + ('\x8e5541d16baa9f841ff4543f56d208351ba28d7e'), + ('\x8e5668c0b74100e35372bbe0ca0863d61a412357'), + ('\x8e584fcc3cf776244a6fc3bf52b37dcfbcf50ca3'), + ('\x8e588b2f34ee00d79e7b9f25eece297a73b5fb7c'), + ('\x8e5a13bac67fd2d6b69dbd9e0bfe9a69d3dd6efa'), + ('\x8e5c5003fac81cb22d5da35883486a199b2a8514'), + ('\x8e5e4a5a418deb6f572ed724fef2f4f2d4cce4cc'), + ('\x8e63534476a99a62bc579cb804f3f01a649191a4'), + ('\x8e700e08bbd493f24c2fdaee648df8a4db6ec1e2'), + ('\x8e85dd5d006249b7bc0c40aef5289137f8763791'), + ('\x8e86f3f25a06f94694e73781b3414db2d866a4f8'), + ('\x8e8e12dd6d7e0faee3404f330fc9fa973a834855'), + ('\x8e907ce6a50521267c691c7283de97e1a0f2723b'), + ('\x8e92b09c63760088f2a29b049f16184aa63c4137'), + ('\x8e94a9d3e9ccb2383d6b3df1721603fe48166ccf'), + ('\x8e961973d318661c39a190fc5b24a651c1ea2c9b'), + ('\x8e967ed435ebfc987b2a72681d0da078b4320398'), + ('\x8e99f254b03f765bff3e2d4635e8ad24de000a7f'), + ('\x8ea080af361ce7e03dc3612aa63bc623276c49d9'), + ('\x8ea0d86d69ccd62c7f81f24479b1594a239943ef'), + ('\x8ea15e05684d706f2897d32fa9e46c3afeb0dd50'), + ('\x8ead82c8b5062a42b828b06a9e514e6df4884e70'), + ('\x8eaf4fba6888808dae07e1ae4a02372e28e3b884'), + ('\x8eafc9b3523de182a2fa488946981b46314d5bb6'), + ('\x8eb70b307fabba708374f9736bc9aec5e796ed42'), + ('\x8eb963b3f0f3279beb716909d2905ae40f599916'), + ('\x8ebe332ff083793c17feca600c6448d37e661b2c'), + ('\x8ecd1677ce42ce422b8784ee7c981c49f292bf72'), + ('\x8ed85ae76edf0e45b3dcb931a0e9fc81b97c6511'), + ('\x8edd9e0cefff7b28e4cfe4dbfd925ddc63e33cb2'), + ('\x8edf5f9488802df2fe53315622754f230d74cb7d'), + ('\x8ee5e0281675b2650a40f586f8d30b3e5829fbeb'), + ('\x8eebc6d7024ff67aceba8f71c42c274242489305'), + ('\x8efaca59bb23f86ce9949e9d1161d66f62ebfa43'), + ('\x8efd9a27455e8538df76e1e4a4b26333f74725f9'), + ('\x8f03d51ca0e1c21e42c93b92ce33b88d90bb8bea'), + ('\x8f0480adfb3a88683fd1923661f1d7891161da14'), + ('\x8f07c771e7160d731b52aad2e920f0de7ccfc433'), + ('\x8f07c8c7068c2f77ef59cfe9b2be9459bbfc93d5'), + ('\x8f118929df2ff6b591e857f7a085cb27bbcde191'), + ('\x8f12037a7c2b72cef5740bbef0ec76d47f877a96'), + ('\x8f1cdbd0a7c02235bb08d7b6d4b544fe160f68b9'), + ('\x8f1ce4c11c7ebda0fa3d28b8e329184e8040b239'), + ('\x8f1e9ad1b67e1c74b78d644517bb9af58c76496f'), + ('\x8f1eb4a806e14de47158c29167208214b8406207'), + ('\x8f1f9abadaa543bbb2760cb37623fa628e216069'), + ('\x8f24379cd675bba29db9c6d643982d3f5a481d37'), + ('\x8f2a22b626e00a5aa74cf53aee011b3c02e6b1cc'), + ('\x8f2db6fc9d54a851e05743c821eb4d92a3a32040'), + ('\x8f317035e677affa946516c82baa17f896e8a866'), + ('\x8f35caadea4ce1d68cd5dde905d928d8a51c49cb'), + ('\x8f35fc47581731738412d7e00bb4f7922d4cb424'), + ('\x8f49a5c11e079f151eb1579d3aa17881950b51e5'), + ('\x8f4a2e3c0537203bc697ae5e32fdf8ab1a43ca80'), + ('\x8f5ef09dadec017c4bf2aa0eacbe6f5666af9eaf'), + ('\x8f5ef77ce9d0a643458952c55de1dce6f6230ba9'), + ('\x8f63b5125269235f98d6678f0e0a9617c777dda2'), + ('\x8f66de6348ef6cb83a9eb682a3b4256db8e5b232'), + ('\x8f6be688fb5989a9024924a515724e0678d7b676'), + ('\x8f81492b093054096870f8e5de4dc97bb4550d6c'), + ('\x8f8172b0252e12a0bd0d86253f580a58b3b2509b'), + ('\x8f870fdd261bcde5b0bf24622520faa80a417ee1'), + ('\x8f8d3f1f13fe57977805ca3ff4f3ec69a2dc9506'), + ('\x8f8e73f988b75ead2ec9c6c5cec4945f05e70638'), + ('\x8f926cc6fe495ce4715c3b708e232aa48eec3a2d'), + ('\x8f93ca697d3fc46dca4cdc4d313d4f33aa604dd5'), + ('\x8f94700ccd580a9a25f2a44eabbef66ccba794f3'), + ('\x8f9816a6ee212322468f417e3d8ab78979290f16'), + ('\x8fa0ba549f6590fdec2346324dab61f254b51da6'), + ('\x8fa80eb7a582896254c30fae1e4c70c0667ac6bc'), + ('\x8fa8d9edd79a1c37d2f323390676470a56f4ff4f'), + ('\x8fb13b0834f886f458e9a7184f7020275b14629b'), + ('\x8fb392aeda61b7148bf4546b9a0cd5f3fa0eb2f0'), + ('\x8fbeadfa37fc5999c7aa7432d1d49beed76b3519'), + ('\x8fc6a5d33cd4f3055e43096b789522d8b651119a'), + ('\x8fcd75349367bcfd748e2007f202adeffb93b5d3'), + ('\x8fd007384671a112611bf2e2152ce269aa0b74ed'), + ('\x8fd5e1e7edd43bea45becce0221570621f474dca'), + ('\x8fda8a17c0befc07f3c23980369482023ada17ec'), + ('\x8fdb9549230730b7612d6df617c2dcee0e0d2cfc'), + ('\x8fdc043b7323a840d197f4741d07fad2ebbd3abb'), + ('\x8fdc2ba9ba394ddf0f179a63fb3a0f1f65b53c71'), + ('\x8fdd89aac39115296c7b1115e93195a457933916'), + ('\x8fdf15305ec2880622ae59d469b4745b244065fe'), + ('\x8ff142afa37f66cda1cdb73255c6b00b1de5ea65'), + ('\x8ff856c424d6c8e80eaf96a3511cae44210fcd8a'), + ('\x9008833542c24c93395d10e987e6b8c1245c8561'), + ('\x900e30895ad806f8e67a47e7bc8b15fb9989dd49'), + ('\x9018726a23842f421c61d3934829ca00090bd97f'), + ('\x901ad4422019514591b47b906d53b767820e95f7'), + ('\x901afb66e79d70319c8ef4833b753da87a82b824'), + ('\x901d80928ddf4b01ca484aa26e0702146602b568'), + ('\x901f177e0dbc5f02ffee6c1302385cc1171310c0'), + ('\x9029f38bde7bf1bcccda45c8d0867438d91f41e5'), + ('\x9046fda54b1cf9a79906564ba7164c26ef546944'), + ('\x904e15398eb0a260fdd690093c93ed73fdc12370'), + ('\x9057242c90e8a4ef83be4fb4d986c22ce0bee454'), + ('\x905e67aa107a43d4532928be263ceee0160b2c0d'), + ('\x905f0d5367b983d3ceaea36887bf10434664d244'), + ('\x9060ab63aa928d651bb43a353cda9795eabd830a'), + ('\x9068393cabf47903a0a2c14e32db51b5d187ee66'), + ('\x9072ece38d5669f3d7a73ec2df38e09efa0d37fd'), + ('\x9075c5b7561aafc109e10424ce27211159845410'), + ('\x90761ce4d1423cbd1196ad78d938af8eaccbbee1'), + ('\x907c600f71e881da22651ed40d3c1e90bba35e51'), + ('\x90891116a64b13a652f06253e0ebe2ff5c6bef6c'), + ('\x9095be71ef0c35972c029d7be83de999f900fa63'), + ('\x9096200ab014320c2e5d0f8a879af7d206aedf73'), + ('\x9098011691c7e8ba622235d7e10141f4cff62fa0'), + ('\x9099b0a3dd90dfa95d81c012d629331d99e2d603'), + ('\x909d234933f2ecc374c4f8d07cdacea3e5b1fe8b'), + ('\x90a47e98ebe140643439bf5d83f33b413a2f23af'), + ('\x90a4f22ecb88a52cfd5ba0c28c8035727d96d1cf'), + ('\x90aa18e7eb6ab1dad7a87f73c1a0ce281e62db56'), + ('\x90abcaa25b54ebaeab083d12bb5534162e676e7a'), + ('\x90acb4c0e7224ca049c52d24d9b002b8ea683dce'), + ('\x90b3c4ae78679c87aaf7af64830c784c4b38f821'), + ('\x90b9589f6e3bd9fb85e9015a71b5d05d807bc78b'), + ('\x90bf0cc2da01f5449936f7915a8701a6b60ffd48'), + ('\x90c32bb7246e563d3f08b401a7fdd2ff369d3870'), + ('\x90c4e03904a377ee8f52a4f30426fa54d2cdd294'), + ('\x90c5ed2fb23a1f08423f5c12e48f59d452874d97'), + ('\x90d1df6c7f086a3888e74168e99adc01e20f066e'), + ('\x90d4fc810df547481a87e36efb971aebdd3a23d8'), + ('\x90d5e5c4eca9a05d94e865e8466076b9d844bc1c'), + ('\x90dbc6a235169afc49fb572fb1c25879893c4571'), + ('\x90e0227511e6550f44d4e49edacb04970d2889e5'), + ('\x90e11a55e6b34862a7f4a90a6941c028909cb15e'), + ('\x90e5e3ab79746c7ee5fbde6a4dadb410f0221854'), + ('\x90ea0f1c4226ec834bbfb073e3935e272fb9d3ce'), + ('\x90f0d3da3b23ea5c24eba66f05b25d901b2e0ac8'), + ('\x90f3e4ad719006275f00c903a22762692e2e6dd9'), + ('\x90f5d0daebfd2aa4258b209579e6d5066101c494'), + ('\x90fd276abfd1cb8f301e951c8d777a50fba594b2'), + ('\x90fda0fcbd450a9da2e7e11fa3b1b126fecc0122'), + ('\x9106cc9c499a655ec0887338e0d65f23538770a5'), + ('\x9108517f72f9744cca75c8bce224a738ceffec65'), + ('\x910e4b2102778a8197776a792a6421a43d9b8e65'), + ('\x9123319119c5dfdcb34aff66418366d194cc7bbb'), + ('\x91265b3c2fd7f4f70dfadcc3dfd44f7a89953bf5'), + ('\x912b0d057122d18f73ec3476ee54785f66504524'), + ('\x912cdfd6fd4d4c666fce9b94ee352dbd6d8f01e6'), + ('\x912dda440209c5830dd51f8a7eae75292e5e23dd'), + ('\x91309bb4a8cf4a556f68d9697db730e3d94aa8fa'), + ('\x913b4aa5d12eb3eba7ae9ab8a820d8849a2df65c'), + ('\x913b70a43606b1539ccb39aa2655e094c1461434'), + ('\x913d623ea1889a82cd5d160599b442691f0f982d'), + ('\x91418a989231303997895de2171a970fd53a0a9f'), + ('\x9146ff134c697881f73e1e300e49b27bb4dfb85b'), + ('\x914e49be393ff257eecb3c64ed09480c312fd49c'), + ('\x915071ece879c7b67d98c29b1212b2f5395f9ef6'), + ('\x91511cc362c1496d82f8d6ea1e5c0966e8a44712'), + ('\x9151a753c97d5171307f935c1740d04599f35169'), + ('\x9153d75f32dafcff9ad0f7b87aaa36688f978743'), + ('\x915ecd644730c23ec021ec810bf4f468d8996ab5'), + ('\x91623f4e576513a7585e79396fb3557f2290f3a8'), + ('\x916a320cada196ffd71c2f724ca0fa2d37b7fda5'), + ('\x916c87c9f1d2f83774353b45f2d1a8aaba78e95e'), + ('\x917ca944d410c8acf1e8278445bbf25b491911e3'), + ('\x918947f21d3b8764e917d2c135bd51cf76867dca'), + ('\x918a635dc5e1556239cd63861848f9b25f319f6b'), + ('\x918e26be297203728ea7e842167dff57c9681b6f'), + ('\x91a24e4fda314d88914f81b3367aa15e2e744248'), + ('\x91a38f58ff26364e7924759e9742658871245143'), + ('\x91a3da0c2b7b277557f01ac5dc6593bf9bc7b55f'), + ('\x91aa71515a5b4b4e7e1e14754068aa18f0805b21'), + ('\x91b55f771812b3834c9799b39c52ef8caa05cce1'), + ('\x91bbcd6e7ede583eda00b75fea55a5fd2992ebc1'), + ('\x91c307b19b4fa47b817ad8b0a0d7e605d8a67143'), + ('\x91c7f1dda28158feb5e3507fb69a8eef1cd3ee4a'), + ('\x91c826d355de944e2ce6ace1ac6583747f84f5b7'), + ('\x91c8fa67eaad386c16860837b76ed8bbde9cc678'), + ('\x91c9ea15b601d120f53bdc79f74350aa9ab6e2d6'), + ('\x91d296a369a21c5f22f05d71a12ed0c53227c207'), + ('\x91d43c2e3581138bf414c5840abe17b59a5bd051'), + ('\x91d6a3be2b3f9cfadf0e3f180d1097acd45baa66'), + ('\x91e53923ae71d891e4bd41715bae41f6e4f277aa'), + ('\x91e61d20a92059a515f95f8780a9c26eeedbf360'), + ('\x91e9048a2486da15e9fda3ecb57e2f90e8e3e247'), + ('\x91ec21227866ca9d1cf77ec13660b7b85ec900dd'), + ('\x91f48aec59b78972f8a3673a10e3c7b9d189b68c'), + ('\x9201418824225c62456c4ecc9354ad4b2cb756a7'), + ('\x9204165a46d05cad7f88448b3ab76bc7160e1142'), + ('\x92077d0843b34b460330f5804d5c1708700c0ed6'), + ('\x92119d7ffeafbbbc17ebdc8d46cbd52b97c5323e'), + ('\x922045b51ef65fdcf458ba9036f02a630f0e59a9'), + ('\x9225e8d56af3f028585b40ee11530f77759b02c7'), + ('\x9233cb0d4ed0ea33c17ccc6920978b1973485b4e'), + ('\x923b2902d24cbf92bedcd9aac240f6ff852b7ecc'), + ('\x92460c5da0f59290c7f2522b884488c23a92a63d'), + ('\x924a25625bc07e383796c2e01ba983b737245298'), + ('\x924e8ea7f1515ff700781c36bf42685c115a7cbe'), + ('\x9251870c6ecacfba13031553f1a68add4e1dc797'), + ('\x9257a3195017df02d62bd41fc3df095eee37f7e3'), + ('\x925a65ad0eac91589dcfc8936c5bfc8bc83ba4f9'), + ('\x925f331b804d896ff263d6a1138055fabf1494ef'), + ('\x925f9a2e0ed8c008fffcfd8ff09e79993f0a9b99'), + ('\x926062200fc7d6b891df89f17d10a518bb94488c'), + ('\x926155328a6d99c2bfb009ebd98f4134727593cf'), + ('\x9265bcfd51b6c77f3d96803942a81f6e1bc6a235'), + ('\x9276fb29f7b4989f7eb8cfebcd165856e782a752'), + ('\x92797e51f6f6f1784f76062ab3db7b86fc137c86'), + ('\x928092771176a1543865e53a699f1cfa8957242f'), + ('\x9283d061623b6409e1f7e8bf4dc47d6d9cb39c30'), + ('\x9283f89d1c9360f948fdb165a12b728757432f0e'), + ('\x9288162972f341163812e3a62044bbea840bad60'), + ('\x928fa179f3ca1733738816d66bcb6a4dd162ffd2'), + ('\x9293750f2a9e82cde534879403d2036aa0176f42'), + ('\x929710094b8313f2489696998978fabe07782a39'), + ('\x92975bce702fd58adf48af0798a0228a62ce0909'), + ('\x9298d14f7f140fc25eda0ce94e58db8e0415cc95'), + ('\x92a173ba18ec25a758480c998fd2fa2a618beac4'), + ('\x92a187c20b4a413cd3ad3874de87d3e61cf68ab1'), + ('\x92a1aa5d5e8836470b6233d1e3628fe088e43c4c'), + ('\x92a941503f5c2a6c233b4bc2e11bebed89b94d4d'), + ('\x92abdefc56078b4c8fe5ebffb1946e2b9396a22f'), + ('\x92ac07d3ea6d15dbfadc9d6f52389b6d42925037'), + ('\x92b177465c3f4dad58a5c5aebdcf6d6d0db4b6c1'), + ('\x92b6d738e855b5dbef2ad151c908853596017322'), + ('\x92c2fa2eb1a9109793cdf7df46a07cda77539231'), + ('\x92c41d4edc3832899de75418f64bd97b627225c4'), + ('\x92c8a8c4b6398cc1b8db2853f410c3288f5cff47'), + ('\x92cb343c856125b4903d61941deccf94aea12091'), + ('\x92cefd8762f362c2385c9329a281b08fd9c00894'), + ('\x92cf0a1b85219402d2a47f0e79fda6ffb88339c1'), + ('\x92d31be455ff37f77691915981e9c6169b871190'), + ('\x92d34d764c003301b002113c1a7ad9fb34d3a426'), + ('\x92dc0d80e60315c3817cfe3d03f7fc38888e2585'), + ('\x92e56cba654433055d57e104c2b3b2ae7b4ccde1'), + ('\x92e9e7f0d6e1d643004fc4b05f55c026e6080016'), + ('\x92ef17d53768dec2df0c3afc2e1dfbeb6a6d36b2'), + ('\x92f15a02d8533a5449df7305ece8a0ff20f669ab'), + ('\x92f2dbf30992aa685f2ac47f2928e521e9fd204a'), + ('\x92f3b19fcf05c345bec14425311144c732e88157'), + ('\x92f49e5b842d33557b5e0a81afb53c02f3e4859b'), + ('\x92f4ac4961a5556c560c254ef7b37744d9b92f99'), + ('\x92f4f73f4f379130f2bddff58952fc1749f52196'), + ('\x92f5d9de2c0d786506f47c72771ed8c1310bcd26'), + ('\x92f638a80ac69704372e51e7fb0cf7ba931e437b'), + ('\x92fc9d49c676fa4c23d17f3a2458b57788bf73e8'), + ('\x93035042fc5787648c0285fee043a32c4f085ede'), + ('\x930f94ee9dc2751a00546baed1308cc0de0ea009'), + ('\x9313d04291d75ae41e74c931d3d8cca8e6934a82'), + ('\x9324a678ec255cd62085ab18f5af965a272413db'), + ('\x932b09c01f3b030e9f3941d62649979b7b29d45a'), + ('\x932f06ff35df1ee2e9ee7e0d229a890f4d460410'), + ('\x93365cf5b937527a7d50386ae3f0d8c5698ca560'), + ('\x933e40215cf6170f96e084777f16456075d3d1b1'), + ('\x933e91475718ab49892d196ec643e0ea05db29e2'), + ('\x9342ba9c67f7e47c930fe95014f87bdd9859d5e9'), + ('\x9342d91e79afe491d5c3009fc43810c8244a6eb5'), + ('\x9345ca4000d546e8f6879bf55a028075d24284a7'), + ('\x9345d94d962060727d3301413022230c84545cc5'), + ('\x9345f6c811f42703a14f92e799be2f3c6f56b502'), + ('\x9347bf99312aec0f107299045c43cf3d1438a38b'), + ('\x934b85352f3613c53655e625715f1193d00b7ede'), + ('\x934c8e1528b641232e2c69a08c95cee41a1bc116'), + ('\x934ee8c9127b52b8d1685c55a4a83d5b904dd2c6'), + ('\x935a8dd9a7b3188950df8690f2cbef975f9f963c'), + ('\x935cc4b28ed096a6e0ba1695c926cd586771d744'), + ('\x935d28da25a7821ece26746aca38092e8884fe78'), + ('\x935e0910ff6b1c7c1b96d2878855eb04f852829c'), + ('\x9365ce948f08196c4fcee7d79867cf0b8e0947cc'), + ('\x936a20b4c21a2b589bdb6e954b07303097697b4f'), + ('\x93796616889d0fc8d4735eedff40e61190ba9085'), + ('\x937cdaf450cfc126d71ea7e77b35af6fd9533077'), + ('\x93828a07f991e8ac9aa49abb9e6b374e62661338'), + ('\x938457f959ca339944d0066d90de48392c0e89ce'), + ('\x9384c8913a5272847a76137aecddaea46d7fa5bf'), + ('\x93857df07c7d5bb5fa52ad658aafd6f1154471fe'), + ('\x9389105071fd93bccf7b90f14750d0ca2dbf5713'), + ('\x939129fc1aab27fe89e006b2b0015fce827554fd'), + ('\x939362784baf9b4e8b9d0671dd6fb7616c20355f'), + ('\x9394c39eca5e25bb1a6f7c12dc637f35204b3747'), + ('\x9396f5d5e46cab15e5601d028b28e442a09a9bdd'), + ('\x9398882a20e849450deac0ac6349e5fe5cb18aa1'), + ('\x939aa385d751bdb019550b1891ba3c2ff3d98587'), + ('\x939b9884987db5f51ba60592d7fa3ec362f409c6'), + ('\x939f4c3531eb163527d691bae2cc49a1893f7170'), + ('\x93a3f3e78ea67328c1dedb00e002b29f7c1a3d61'), + ('\x93afac7a35bc0880e388423bd3e74eed91e5604a'), + ('\x93aff13b20e650c1430dd981c412e2b72b45c227'), + ('\x93b2573725779d5b74247fc4c24c25c9f0eb8101'), + ('\x93b895cff6d72f13a74302fd3739635b800393ea'), + ('\x93bc573abe3c1eb1567ded8cea4c262fa80591f4'), + ('\x93c1ae9723edeae2d8e9ef77f0961ccaa0734576'), + ('\x93c3a4042fc90d8fe77767af4358ef331e84d8ca'), + ('\x93c802efea4a94515adb86c93ade2498622d5f88'), + ('\x93cc72987d30ce28be0ebcb3405e3bb68702fca3'), + ('\x93d3189609e0c2988b2967df094ee579092e66d0'), + ('\x93d988795cc3910213d4c5929ca2fc78b8ae93ab'), + ('\x93dd2631ac345c0fee1dfc9d402e84da551b452e'), + ('\x93e6e509c7831702be432f35abd1334364dd437d'), + ('\x93eaa9b392f96b34408c810c19381931406321f5'), + ('\x93eecf10ad6f73e7f48a4b0e9d7e501697e09259'), + ('\x93f7d1d1d7223dac021b2ac2c2117105443582b0'), + ('\x93fb11381c49b2d84cf3df2e0cf6dc0d90ca6f48'), + ('\x93feebe4298e8bdd0eac83d57040227b43acfd03'), + ('\x9400693797f404829b602f22be1bc0e82a2b1ed2'), + ('\x9404fc24205f2bd1714aee04437d3869e27ec1d2'), + ('\x94050c4a8ea15189577ce30c4ef038ed5e8430dc'), + ('\x9405e103f46d71990e9e47e2dea22ba8555889c3'), + ('\x9412f060d43fdfcf76b89b55a75e02b560937037'), + ('\x942323252baffad224bc0bff6e307b36cc286612'), + ('\x942ec3669cf94df4eacd66f6b017ed0b300484ca'), + ('\x94331fabb79c1ad57080b0446f2423bcff596c2e'), + ('\x94375bf2e60b01841e34126f2c4677da98bf5b2f'), + ('\x943c8f6de9ce3e72ccb85202193a20349d49440b'), + ('\x94429ee8adbc689a6d898f5d345da768b8cb72ad'), + ('\x94463e083ca626a67e3a9f43c71cbefa1ead4697'), + ('\x944b9c4febdc9e002cbb52cba332659ee4be4e4b'), + ('\x944bfc85ed98946e1915912b88525b71ec5d3590'), + ('\x9450b021d072b3b30a67a6ed6efb9d4b1acffb90'), + ('\x945225b6412b2b1b129891e3db8530bf45927bc3'), + ('\x9452dbd58d06242de3c14e8a14faf3de2fa3894a'), + ('\x9458982e57f1323cb78a9729f122ea88412d8809'), + ('\x945ebb69877a0d0dbd13cebf9480e165ba99fd4b'), + ('\x945f63f51f1fc9e07c8fe6368c39ef7bee0a8496'), + ('\x9460d1222ee8bb560f05ce339905d54d5300830f'), + ('\x94707111699b94d5bd96632d16e2181c0f009331'), + ('\x9474b5655d87db9be06b2013193fa572c6204fde'), + ('\x94779257b303502c1a5fbc01eddfb41bb3d2bc84'), + ('\x947859255e202b8d4551886b38a8f84d37424a47'), + ('\x9478c1e91f702179e85116cd2d477b2e071ba6b4'), + ('\x947aa9b017654c108cf2c38e1f51f21e49494191'), + ('\x9484a9b6a3d177241ce499819595202777da6e72'), + ('\x948607397d4e285cb528282093a87600a07925c4'), + ('\x94876042bd303c59e55199478ddfe8335923bdbf'), + ('\x948aef54c0bea4044261b4530f1b03be944a0959'), + ('\x949c29ac48272f9c29df7e99408438b8a5f0000f'), + ('\x949ef1bb4ef34952be268a53f8cc70da457f3782'), + ('\x94a9ed024d3859793618152ea559a168bbcbb5e2'), + ('\x94b15bf0eb92f09982aba9b899705ec2b334a5b5'), + ('\x94b287e78e3ba6644f0b1967c64c09740a2e3926'), + ('\x94bc93e57c8c1dd535859b7c3e59f64193b86486'), + ('\x94be145ed4b9af5a6b0d2173698cc0863104e632'), + ('\x94c0ac9375c9fad23b3f6f910c31982036124e7c'), + ('\x94c5ec2c0537ee4487f7df0ca9352bbe9615193e'), + ('\x94d12690eea22d0b5cdac9a72dbdc2ea76240d40'), + ('\x94d1aca3028b20b5a7ebf07600d005c0cb101121'), + ('\x94dfda9f35f1219bb2f2d006d7154ccf95d16ece'), + ('\x94ebe267492a30369896247629a7b2fa0119b348'), + ('\x94ed27f0aa1dc8a3173eb4cc9ad5c2db9121741f'), + ('\x94f2d68a4c3bec6469fcbacdac1b2ded9ebf24e5'), + ('\x94f5cbca6da06ff88cfb47c0755cc5a8968658ad'), + ('\x94f72e873181c2e810f4ae9570fe31c0e45df884'), + ('\x950e023a5fe03e2e4ccd6a315ef63ef5a310f67c'), + ('\x951224f850d216ddd9dc1372a64efef40d12de08'), + ('\x951b89355ee81c35128aacee8b33c3350fb41b42'), + ('\x951bc5ffc3525874f8a4de7f5a7c179e5f560365'), + ('\x951bfd83e89ea1370f4c83617d927862c22c98ca'), + ('\x951f7205bce096a1c127df8b88e3dbfbad12f373'), + ('\x952e1476b5d23d05fd7e0ec142bedd66cb5fa7df'), + ('\x95310c958e25848254124d063982c2d00543c8db'), + ('\x95330b5ce19d0eae53d1eaac83d8cda595c86319'), + ('\x9533f1d61085be3331da1fafc79e3ee5272503de'), + ('\x95362644f97c256ed51e1060a75b321b7bf1ba5f'), + ('\x9542e9873e8fa17175afd6bb358d7f5692779b19'), + ('\x95455ee70257d61f63b7fc159d052af021dbb281'), + ('\x9556a7bda5cc2c250c0145d76a65b1bd3954fd2a'), + ('\x955aa75dae63892249bc37cc18772fee76ea56bf'), + ('\x955afd600b1618330cce696d9b5e44136e1c22a1'), + ('\x955c45cda28394973c5e298c4c5c48c068e6c20b'), + ('\x956314125cecef43c2e974b7035f3dac24260cd2'), + ('\x9563756bac1254695c6415bc5ee78f518018ba4f'), + ('\x9568940069f7c1ec171fe8a69a9574b4a8c04b6c'), + ('\x957a424a99f120c790b7d07e9b72bf1df1fd6d37'), + ('\x957baf88631908a4690ad6f19634add704a8c97c'), + ('\x9581c28f175a095990d0a86bdc3ce1ea720b1f71'), + ('\x958781a5417bc38cb1af005abc03e77b0ab823eb'), + ('\x9588ac134938759acfea28e016e7502261448554'), + ('\x958adadb9d2d8f0cc9195fe8d1ccf39d0e814521'), + ('\x958f8b24700a754ba4bb148d95b964c119adccfa'), + ('\x95910fbdee598fa31390559f4d4b2b4f327b25ed'), + ('\x95a329332ae4958f6e238ec28ceec212a2df437a'), + ('\x95ab0f111be2521cffa80c19a84c366ab0b41994'), + ('\x95aba9fda0b736bd390c4d904a4e4e638a69b968'), + ('\x95abbe420e15d7cdf6f380eff55d41bff97786ee'), + ('\x95ac64fadeff629201758f612ce31f0dc8bd4187'), + ('\x95ad949fa2ec9e41148ba89c34c1bc39b57f6536'), + ('\x95b4b5843f24a3b6acb0a2828551748ec7a3c09c'), + ('\x95c0217325e7632a9932c9d420aa62f2d1c43e5f'), + ('\x95cb4512eb99bca4fbec6529c9671982dbb88aa0'), + ('\x95d0644f17bb9d652fac6dc1bf9f9b41ec19a33b'), + ('\x95d41e0d955cd4f5a672cf674cde45b63ce231ec'), + ('\x95d8a421b5f205b1ed01b9592d3bae5026133bdc'), + ('\x95e030efa5f0fdf3759eb1aea6d74ac21d1e1aff'), + ('\x95e0526096501f029dddc94bdb3cc0a52365329d'), + ('\x95e5a443404a691254a413bee51d1a54dabb0736'), + ('\x95e5fc9be2844cf3511bd3b22026f3f506bdbbee'), + ('\x95e75a369b781cbc2af421e26be1dbd5774bf90b'), + ('\x95e76f3d4e72c27196ef0ff55b90165a3c65cd89'), + ('\x95f1b9ce5e388ff364d38f310b66a9557dffed2b'), + ('\x95f8bc282586ee4d76346ca727ce5bc8b4e70b5f'), + ('\x95fad0991f3db2049eab6f9e9cfdc73956ac0971'), + ('\x95ff75392b424b9212100893a4463ba0317f9d7e'), + ('\x960b931198f3b93de51ee43687932cdd54fbb8fa'), + ('\x9620be4d791b64d50ab64e95905fa2bb8828ee16'), + ('\x96281a7aca545fcb4d7a6b07ac763dae6d836890'), + ('\x962a07017f8bffcf630926c6670cfb2f797a73c8'), + ('\x963799caf57748094f1e5b93f75bc09b63ce1de4'), + ('\x963c2c00d4ad22e9a667d00394841d6bbfa5f58a'), + ('\x964011b58fa539cd09cd680bc589b856e9d6c0d5'), + ('\x9643260e8f39e7cdfdeef6437d51da77582e1e0e'), + ('\x9643583ab07ae00a1447cf426fb50b2aa8c749c1'), + ('\x9648e557185fd461d383fe6085210513b24edd41'), + ('\x964a653b428b60ddf3540a673a53a7cf2720d395'), + ('\x964bf22500676fa70aefe24133c10f1c9d069148'), + ('\x9650035dbc950c7476de43b271eedefcc7d4ab15'), + ('\x96542ddb29226c3ef1f7566d718954c6d6e7966f'), + ('\x965c07a0cf66022321265c398fcf04323820b882'), + ('\x965c4aadeb615591738e3a4d47d1f374319d4d33'), + ('\x965ea56a674108d4b725ce803f6ffab46b82f0dc'), + ('\x9671285f438fdce6cb94dd79a4fe5cd744eb7ec9'), + ('\x967160a57b0767a3545f79d4939052a12012c3a8'), + ('\x96790445d460cbc7c745a6a6f8d751b1a19fd69e'), + ('\x967d51e7f194e7dd554337a8938c14509134828a'), + ('\x968255df2ba961c06c1c9042221fd82d4e8708f6'), + ('\x96837c3333628d097649755ef5915830ae96a13f'), + ('\x9683b87fbb0b6d8ff75f76b25bb7dbc787cac3ea'), + ('\x9684166ddc24ff7ee0f4c13ba2154ed230709625'), + ('\x968e405baa6dda783a61982b491c603b5c113e3c'), + ('\x968ef3bcb5e5dbea8a025984db757b2eeb4deedc'), + ('\x9697283215450105faf28d4b7a3137b1e5805ffa'), + ('\x9699d24c0f53b38883eaced6e19f926875c6cff4'), + ('\x96a1d00b30b6da016ab63e09b63afa9cf98fba7e'), + ('\x96a2ad4e3add43b21650a04459a40960072dfa46'), + ('\x96a3111828f210d759b9b1e0c00e1171a5cf33e4'), + ('\x96a314ad72cf328ce44363c7514c948ac8589fe2'), + ('\x96a64c2cc88982fff5a631cfc98c2e7aaa33a4ac'), + ('\x96a7071d508c7b2021728dac8821662e10047e03'), + ('\x96a7fbb314cc3f14f3b6d5b196c001bfeca19bb5'), + ('\x96adc6ca241b0d1f81b698868531425817e7ecff'), + ('\x96b4a5a83016ce8eb12284bb8ad7ad768f82f005'), + ('\x96bfb413000c3ba0491723a8c90e68ddf6831cee'), + ('\x96c284418b46b281754b6bff81fd6c98836fe7c1'), + ('\x96c37fa44b10a5578a300a856f470908aa37fa57'), + ('\x96c3e52d49441ab98e107ef040375bffd4a700aa'), + ('\x96c6804f2c1a08c046e70721b907792843e783b9'), + ('\x96d63343bf8024ee682bbeb9e9743c477ad0eee9'), + ('\x96d7139d6bcd216e071b1f2f327a555426658ee3'), + ('\x96d7be5e21bed89743c18fe295417bd1a52cff1b'), + ('\x96dbe54d27806275b20e07d76349d7f9fdb2cde1'), + ('\x96ddda13e2d62fcd3c7465ccc4661c1d481c29ec'), + ('\x96e1f4501babeebe6a4d0a5f487051bd60ff7b1c'), + ('\x96e87614d53ebd9a72d6584d707aa200b1ebe3e6'), + ('\x96e9f7b72c8385455a38ebe8271b6bd2ab345a10'), + ('\x96ee86dd7c975b519c59b7a767a46e25c88213b4'), + ('\x96ef52ca447d2aa84d73f2721afc72e4401256da'), + ('\x96f5db4e8e38f2d9d080d0f00b6c3e213772df33'), + ('\x9700ff2dda28b8c13ccd27c8dc3032b72b734a35'), + ('\x9706db5453e4ec25a75c722abb0579b3ce1e5cc3'), + ('\x970bdfc93db2399958f855bc4ebb67209c68fd35'), + ('\x970f064fcbb01a49d35fdbf76f3cb7ead09041c8'), + ('\x9711e39beec03ecf3af71ab279bed24a329a2628'), + ('\x971692c57d45e22dcfbfd05171062961c9b5de50'), + ('\x9716e31e429d6c923494c8ddc66640e28c133955'), + ('\x971b6f64187a30d864d558d3307581e32ff77016'), + ('\x971d1412665dbbe80f444b516142b91e651aa946'), + ('\x972d3c6ca86ce6a8ad146a9e93a7d8e1bc9ca1ca'), + ('\x9731e73a588f3f32ea8a9e781921054edf735431'), + ('\x9732066b31ca4ac2eefae28a09c3151941317f44'), + ('\x97385819fd7184a4fc944af51c1183c70151db7c'), + ('\x974640f097cd474fb95aae68277fcd183241ccb2'), + ('\x97471d0bdd83bdc49bd3a2907a63b938dddedccc'), + ('\x97477e229fe9422d8afdbf751ceb48a150f836ac'), + ('\x974f44a09df8d31013cc23ee0801abcdf3a6db11'), + ('\x974f8a51a627918275c8ee0f05c554bfa8dba1df'), + ('\x9756f962a4dd7e0a723abbfd7f717bbc957fb01d'), + ('\x9757b20f09446fd7c454c4ad5845bbf346a945b3'), + ('\x97659bf27e146c5ad4e9a59a5a6d56f8ddab3b16'), + ('\x976df163f0e073b156550f8f1925da544ea9ff94'), + ('\x976e2bcf4135f8816ecd97ea2a9560ebc98cf875'), + ('\x976f476798f4f191a9a7f989b1f39dc060977433'), + ('\x977467bb106db785197bd31ce7e427ce70e4d7c8'), + ('\x97768854e7ba2f89313c98ee8209c8999959a217'), + ('\x97805b9d9859414a88000240e6f07f77dc2ed20e'), + ('\x978a2d92310f003a4531d83c138890c9a5ee32c4'), + ('\x97a3e47b2a1973dc6395cd95b76b3d0105bdff15'), + ('\x97a52a3044a175a0a7c010ac8dc387f23f7a37f5'), + ('\x97a794630348eccfd3d1db1490e7ae1d4f40ddaa'), + ('\x97a8b0e34e653cee66a5d09305d19c7d63c7a157'), + ('\x97ab260b512d7db0a117203df62545e07e16ea8e'), + ('\x97b2a618e254b0d89368d3af08acc0de94e9aef1'), + ('\x97b3465330789ab1dca42c9781cc93ff77554478'), + ('\x97ba4dc46280fd993cd773ec1b9b151ba67b51cf'), + ('\x97bf825670fff1468c85b5c4589cd9ca3a5640af'), + ('\x97c51edc3e3b992a68650fa364b70e658938b4f8'), + ('\x97c5848ae03c8870e5b8ddbdd81b3151b2b3317e'), + ('\x97cc9460f13975a1acdf784414fe4f18aa2cb4a4'), + ('\x97cd0c429cf318aac7f9d66209c734f0a547b901'), + ('\x97d67be16320599f688f7259e9031db08854c1b5'), + ('\x97d7f7371e7fc8c362e44f06b7c6be90ff824071'), + ('\x97ead9f65c34924d0cbc4ff981dc90274ff5ed6b'), + ('\x97ed61c257eafe998c35010903ffabf9a3763968'), + ('\x97ef55d5fa3c3cc2db01baeecb6c0fa64742020a'), + ('\x97f1165a17d53af4a482b5fcf0d99af827197c8e'), + ('\x97fce1c4ef657706ae0f2d0866545f2306c4f2a9'), + ('\x97fdd389c1909188db094947c938dcfa2b1463c4'), + ('\x97fde8060ed2455a43a06ddf0a2f27e7b76ca3d0'), + ('\x980a4be1e32eabc1e36ae9d5b9f9ed15311952cc'), + ('\x980ad9167365d0fcd356e6a2d0764b331f6cec3f'), + ('\x980b806a3cf510cfcc19727c327ebd31bc43efc3'), + ('\x9818bc55c29b0f30ce75b4e527787890ced712d3'), + ('\x981a19f1a2d0ac1e2be63407b521d3e695a4373d'), + ('\x981d3b084f252d6b6738a69ca604f7da59c11a55'), + ('\x981dc900a691f0297c8539b9573a76573f318712'), + ('\x981ef8d7714e9d050f590f35942bb69dcc9c55bf'), + ('\x982737519eea5e1bfbf89179db22c256adb8c2b1'), + ('\x982ab39b069930f35ec350191b8874a90d3a1fe9'), + ('\x982e50e7f567f08c773ff659b7f15fbfbdb1040e'), + ('\x982f7a5c192fb17ea3fe3597b31a9ec38e0bb04f'), + ('\x9835e8f52b056b8c0cdf096ab03fa40320be041f'), + ('\x9836ba872a6820e8175be9881b086660320f09ce'), + ('\x9836e02a3c006294d9a35c6c77372ea9e6161c6a'), + ('\x983822353b217ce5240d01ab79088264c2ccbb2c'), + ('\x98385dc7d7dfe834b360572682a292ee73efa5ba'), + ('\x983acce85b552e9e6587324d68dd88aea6283e47'), + ('\x983c2cade64811eabc939a5e26a0a74023938e95'), + ('\x983c96458bfb2b42eeea6af89c0404b35d0aa7b6'), + ('\x98416150f0faaa07cd8b5ec6ae411c03833f2a3d'), + ('\x984796c500c9facef168d806fd090cbc1def6b73'), + ('\x98482c9a3611f9bad1cb601f57487d0dff9fc6ae'), + ('\x984e2ba41108124d427f1b0db50949671752d7c5'), + ('\x98523c25d4d516ca7a63861b904cbb584dc3542b'), + ('\x985522393111dd063078b1c18e09afc21d29eb4d'), + ('\x98561c60a56db5080f633a09521d85d2391d9cb9'), + ('\x9859d40cd24278f3877616359a2749b4f08f01f0'), + ('\x9866fb5de9e4f63d166ea4dc17ab89d52ff4f420'), + ('\x9867aa94cf61f837ab935c79a362c4e32a5676c1'), + ('\x9867e404cf959df0dce6ded5222b466c788fb840'), + ('\x986a6a0daa23b425211337130b3170325792261d'), + ('\x986bda979ae499635da19368b11d6885ebf789cd'), + ('\x98769aed5d2519249ac0e5089242328266b9d580'), + ('\x9876d8bfbb5b23140a452750f1f420ad66190524'), + ('\x9877c392d7f63521547c2b4c54dbdf80147b743f'), + ('\x987bd75aa64dbc83db01d8016db42850f927cd6b'), + ('\x987c1613907ac087a17a22b913a3c1396a6dbdba'), + ('\x9884a5c07a2b1b9391a8e0756b4ea9145678f16a'), + ('\x98855afc3bf2c7f6477ead456b8e4912f9a16749'), + ('\x9885eeea0dc3d4dce254789aaddf261871d4911d'), + ('\x9896ad7d0dd0c60bd622e6f68bb161b145f3ee14'), + ('\x989dc0d236067d31b8ea88394dd774ed1c8a4b1f'), + ('\x989e2c59e973a05cfbfe9de678b7f2af777b0713'), + ('\x989fb497b58891b9d96041829df24da654210160'), + ('\x98a8de0b14fba5201f212e1c5436861854300525'), + ('\x98aab9ca96dd415c1bbfaeb49afc5ddc1ffbd7bb'), + ('\x98ad9e632fbe72d676f3809a73c4a75172f7251c'), + ('\x98af417e28e136cb694bbd77fbcc7ea04cec7724'), + ('\x98b75b0b80a391340a5b7157e7eb0b688f231345'), + ('\x98b7981211c6201ca23167335a50e33ceb342aa1'), + ('\x98b9173529b44366820ab97ea34e81702b995872'), + ('\x98bd2dad45392ecb47a5348317bf9eaa366c3a18'), + ('\x98be8f94397dd9dbe2ef9034e080833b233b249c'), + ('\x98c05a8e6edaa0b74e50a31aefe3815fefccbbff'), + ('\x98c0b7856e56764856c5cc8c42e7118e9851653b'), + ('\x98c7625cf2e607a7f1b1d7d800b2e25a75daa659'), + ('\x98cc45e00b7b836ce821c298b8a4f1dbf4e64909'), + ('\x98cdb28fdf234ee4a6b34232950e827d4ad6413a'), + ('\x98ce1fb0b2efd74d72e78321a1cfe9b5bf17b58b'), + ('\x98ce6c47ca8875d8ebed4f7e46a99d0dc112a60d'), + ('\x98d2167980dcea38980d2c58a8630d8be1b0905d'), + ('\x98d26552ee37c1aa412ad739876a4d9943cb2df0'), + ('\x98d757293e18090d6df8f59434a22ff7be829ba5'), + ('\x98dbee74d5d159649e4de157068c482dbefb60f8'), + ('\x98dfa8934e10d727a9188d9b77dcdff2993f7e5e'), + ('\x98e31fc18bfee64199468ef692d22070abc2b95d'), + ('\x98eb069fe3edfc7a8eacd98f10b4e8053ed2345c'), + ('\x98f4ad95a5201fb16233ff909e76d6f50af28d2d'), + ('\x98f8a311c72f30f1d67fa7409c57fb2ff79e9b5e'), + ('\x98fc1dfc996dd0a688a5aa3d8ca31901734f4f53'), + ('\x9900f543122f185cf64a027ebb00e4f4c7c6ce2e'), + ('\x9904a925e6e9718fb28b28af852174c0beb18f1e'), + ('\x9905d371edb8f9433cb111ba3c6024d34603185c'), + ('\x99094da21afe5b59f0c7b0a1671315b63d5e817d'), + ('\x990d558a242f77532a293445d0e633983a93fb5c'), + ('\x9911a043ea02e5c601588c287d2f41892d9bd360'), + ('\x9918a69bc863069fe26d13ca8bc5a8dddc0f398b'), + ('\x991943a529822d13d762e36f5eaf0fb72c20e54d'), + ('\x991f8310d570012ebc0bc0f87af4d0a1abaccc53'), + ('\x992275c25d74bfda4bd352fe74f49cf482a0f412'), + ('\x99249ae3b49f883d09e16aa0f61cea311ef7ac08'), + ('\x9925fd8a26810d0e8e77948b0c6ab1fe7a832481'), + ('\x9926b7f6308c985a8bd19bb9169ae021c01645e1'), + ('\x99279c8058c7815e3da654aa42fbb6daf5bb2960'), + ('\x99281d0cf6e5d471194405c6466b963d661edb73'), + ('\x992adda95d3877817db039cf56285faba57c75ed'), + ('\x992b8581225702c8e3a1b56019231144ec473306'), + ('\x992ea57dedf8b889653322136680e30cefffbafd'), + ('\x992ffc566cc050f796beb979fc568622f5b761d5'), + ('\x99314dd0bfe0a2cd59befa47f404872855f1708a'), + ('\x9935254a954e933e76f3d62189e8585199bb133c'), + ('\x9939a1d0f27b83c8ad3d3b8b095fb1b28ae2195f'), + ('\x9941813512e40ebd81a1c5b93cf80f53e3f3c6e1'), + ('\x994e5187c63d371b5b8ff718732f586601bca64c'), + ('\x995cef4d267be808c483c20bb84366a613e5590e'), + ('\x9962aede1b1257f97b73172c5413b9068483d70e'), + ('\x99640e6231ec200f647d9531fdef64fc5c11dc31'), + ('\x996817c80bdb51490e991177276add63bd6f2146'), + ('\x996961e825dd26ec792bbdeb137223f7fed25421'), + ('\x996e596691521d9b2701c0469d333b9904342828'), + ('\x996ea32236361a9e05bf70c91def87e7d6597770'), + ('\x997485ade21535bb16fda2637f1abcc9154416e6'), + ('\x9976b2f72ea5d1f9ded5aad1e75062aa1fade189'), + ('\x99834f907b71c1af065280e29bfe109ebc416d26'), + ('\x99944e5ae8c316d81ae09170558e050a00717ba1'), + ('\x9994a89a014e1be253b4656ce160538b857cbe51'), + ('\x999659edb25b785007a6e82f4b361c729ba5b39f'), + ('\x999a3811f5ba102c4c3ea4d8b4f646166571d766'), + ('\x999e8d1b659c3be9446a0d7f4e986df0d9f82b48'), + ('\x99ae7d8c78f5884fff2f5756e1355e1a8f265216'), + ('\x99b2696fda94578f04ab9eaaf3d33887d2e99966'), + ('\x99b37d55bf77f370d0f0743deb3e8cb22fed16a3'), + ('\x99b9360cabdb8734b47380af1651ed3897dd78be'), + ('\x99bb8a38a7a468cb45bbc7e08ba478e35ada1d1e'), + ('\x99bdb4b25cac305cfe708be8271c294ee8c33f13'), + ('\x99bfbe35f0043d0b363aaa27ae453382b85010df'), + ('\x99c9d0ff54ef3f3dadbfc2ccd9b5c331f8865277'), + ('\x99cb030e4eaf57d300f9749329a210e1aa1e7ab3'), + ('\x99cffc86b64b13e9108e2f24ddea6a2ccb805e6c'), + ('\x99d2710cca487af609e0e728c69d4eff4bbf3b71'), + ('\x99d841e9e9956416c50b2b41c3e52500bf484dcb'), + ('\x99d90d09fe8f0fad1f0d0903fda8d3b95ed5c7ba'), + ('\x99db86aa0282e425450c86508d42da77a9f4102b'), + ('\x99dc382cbd758c359485080795d58d5c40a7705b'), + ('\x99de044c4ee404e248332c1ad918a8f891d511bc'), + ('\x99e20a279658d611958c7b71ddeac88246fb65fb'), + ('\x99e37eb9b3a4215edf405c5ec38dc2c36ed1b714'), + ('\x99eedea35f0e5e169072a847dbc623e457d4c9bd'), + ('\x99f3e3fb8eed40de0380e515b5ff6896c401865c'), + ('\x99f6b097bfb0d4c70401a268577a045ccdf49947'), + ('\x99fae0634f44aeae4a8bf0aa3f4cfc5b50063932'), + ('\x99fce72042933c3da35fd4fd83516fcc7e3cffc7'), + ('\x9a15c32ce16371263491ad7c2278ec3744487f94'), + ('\x9a1c0a1b7051f18cee11aa99bf7d5b9390c641a9'), + ('\x9a21d4b15e0c1e005ec75afa09a9a2904a0226a9'), + ('\x9a2932d0f56e72de43a882bd5e9e23cbbb167480'), + ('\x9a2aa314f2c895d3f6146e34ad9e77cb907e3d75'), + ('\x9a2ae55c2440586e36863275bf4d7015e427eaa1'), + ('\x9a305ba222657707eaff2bf13eafd50d5aa05ea7'), + ('\x9a31e446a1b0bd51707a2a1134e02869398f7b68'), + ('\x9a34c93c56a5e8473f25c1df695541fb622085bd'), + ('\x9a3b0235b57ae64fcaed43333feb8dce9cdb249b'), + ('\x9a3fb7af6611cc5967b92be971d7702098ccac6b'), + ('\x9a4c1402bffedba4ddb0f99798329ff7e416f577'), + ('\x9a4c64623f5360db5b7a0ed6159e0a21596e8d29'), + ('\x9a4d26b2212845ad19f0a8a88bd59d70fabf5262'), + ('\x9a4d5a1c3f0d05d53cf9a35fe3b492b15b81438f'), + ('\x9a5208cb12e4b204a69698a63195353ec8788447'), + ('\x9a5339427b4427070467b455212f7fa0a72aaaae'), + ('\x9a5560ea3158e70e960f39c6c90ce31e745a1604'), + ('\x9a56696d879a2d7c99d02899d4b0662790621876'), + ('\x9a58e5155a75a6e71f68ffa3604031eeae11235f'), + ('\x9a5bf1c217a4671133b818c7ec83a026bdc720a2'), + ('\x9a60a4db61176e418f1e9abfb7fdc82ae2b18e62'), + ('\x9a617e204f5866254f1a4669e0d94b7a3305b5c8'), + ('\x9a691e47c1212395741d53f7b8279896d5b22c6d'), + ('\x9a6a0b8c37612f0e6f74ee1c87a84ae82c4497ab'), + ('\x9a6ad292f0eb5be3bf7d152f8bc8b351ad8a03c5'), + ('\x9a6dc2a97177f224d5b687154ae24d6f2ba96e8a'), + ('\x9a6f04312e72a61e4d1f2d2b438d92c529a26f96'), + ('\x9a75013d2d64c28a82ff4fe49262f58d73375461'), + ('\x9a7c7c430e6064a018f4c07b649ae3331e90b5cd'), + ('\x9a88de358a6081f7becde89f96ee24e2555aa22d'), + ('\x9a8cdfdbacbe1ff96facca193ca4cd5d3f9fe3e0'), + ('\x9a8d3573bb59150e47887283c6949b37689ea210'), + ('\x9a98fb43fa52647af3ca1ab6c1bacc932e4319ce'), + ('\x9aa3d4bc08ffa003145f7896c0185c464ae609c3'), + ('\x9aa5640a2b945bca835c61ae6df2cc6ebb1dbdc8'), + ('\x9aa92c099d8cfaf76cb0b0bc6c6e08a1c14139d5'), + ('\x9aac4d3324556e0c74ed62d206c8d61b3f60cb8f'), + ('\x9aaf26731713f8952d4e40141dda1e0c4692b9c5'), + ('\x9ab19d6831c6f22d13dec6a9410aabde0a673f3e'), + ('\x9ab2726b8622c04dd190a059ef91cb5da438e716'), + ('\x9abd7c05a2c6e26ae9c4862bcb02a5bc025a6c76'), + ('\x9ac3af6b1e7f443ac15114f6ef2be9f21d2e527a'), + ('\x9ac5e08bb331ede3f5bc5506d1247c69f948b4c4'), + ('\x9ac7029fb93795e4b576e32d099e57f77f7666b8'), + ('\x9acbfb60527daab4853df433d2b450546d27f34f'), + ('\x9acdac1f3b2851ddbc56474c4114a23f36b91d98'), + ('\x9ad1b86ae5013640894adfda7c1e5891d9b9fdc2'), + ('\x9ad4f00691e977b0a5d7d6e56f4a41bc66a3934a'), + ('\x9ad61e64afaa0bfc764b37d2e597fe07cb314aeb'), + ('\x9ad6411722f181a32429532d77fbfc2f9d108a3b'), + ('\x9ae02242be774a61e229da799333817e7638fbf5'), + ('\x9ae44647bc37e7d1eec30e4813b84ba8077d0a5d'), + ('\x9ae571535259c88c8180650b222a5c4478aa697e'), + ('\x9aef300b232a9099d8a01e468385bbbdf90e77ed'), + ('\x9af05b8714e7bea73d92ebd39066831567ce1cda'), + ('\x9af06480851f14a6c5bb0b10686298467d0fdce4'), + ('\x9af16273bd90c5735bb498bee408727583528df8'), + ('\x9af35222871f091ec04ac0f0b07428b34a75ec54'), + ('\x9af565927dce4cd2378543a350bddfc3e23ecb16'), + ('\x9afa6d324bcfaee3c52c8a7d2d036043a7e821ee'), + ('\x9afa7133e31ebdb0188bf1e00d8ffbdf6610edd7'), + ('\x9afa9d4caf2dbaf814392e58763edfbae3f4bf25'), + ('\x9b06e57e86de3357effd38042f1a59b72174f507'), + ('\x9b0ebd8389a6fbdf3417f4548c3261fccec137d8'), + ('\x9b0f36bbe690231e0e01566eab0e3817c5551db7'), + ('\x9b105600b10d429308ca071bcb72f169067ffa89'), + ('\x9b13bf5757675698265d6d6cd728399ee0f7174e'), + ('\x9b14a2168b23dea74ec45dce3b2d0e3090e67f97'), + ('\x9b3322affa951f04c5dc12dc6b5a2478278660c1'), + ('\x9b345e53353dba77c0865bcadde5e627202b10fe'), + ('\x9b350706c71f07a17ce6abb0a2c0fde7d5da7775'), + ('\x9b398aaa0b16639416259b95ccb93b52c3126641'), + ('\x9b39c5a579609d697306b0a2590ee26badc33863'), + ('\x9b3d365b11b4e35cb973ce7cb9a061370e567544'), + ('\x9b4098b66a478896de97d433482c944a1f3daeec'), + ('\x9b431fad0d4079afd902592878894a32b623a159'), + ('\x9b526bfd881a70c0ea392206c3224388b104d3d2'), + ('\x9b52e7515534a6248bc557d403c8b111797dddd6'), + ('\x9b5c67fa261c8a17eceb096933307d8d0164252c'), + ('\x9b5ec81897326f482ca106ea282602466b35fd5e'), + ('\x9b5fa68ca06f73ed02e2982a8f044f8152dea240'), + ('\x9b605f5e3b1dafad6beabe7862ced7ca407f75dc'), + ('\x9b618d38885649652d64ad772826dca4b83e81fd'), + ('\x9b64ed2ec086bea348733ac74847504bc8b3c933'), + ('\x9b6ff1f30214ef703538e9625fb34d7f57db2814'), + ('\x9b713fd687a79c60eb1f35d1e82c4ef644cf5801'), + ('\x9b73c9ffc0a3c3a4fc0f947c907ffc415238b1c2'), + ('\x9b7835c2deedca3eda78e10f5fd58e6659fab217'), + ('\x9b79e911a08b0718079f9a5f4a13ffa58cbaf65f'), + ('\x9b82fe661b92fc0c080a8eb832103c085721628e'), + ('\x9b8425b00e52b1cd92bcea87d834d1fdd278cf31'), + ('\x9b866609d80776f22aecf79ef5fb97babbd6bca7'), + ('\x9b89b98b7d14e85ea34da74af030c0c73b8613b3'), + ('\x9b8d4f08e36e2178f76a350c2318ab8ec5ca0b15'), + ('\x9b9436c7d87a9e048b734485cfd08b72fe9b1b0d'), + ('\x9b95ac6c342041a91ffeea2e2838d8427e6dfc46'), + ('\x9b98e3e215b58735cf3e982f01e719c81f493897'), + ('\x9b9b162f59a447a9b2e7099d375f471a43d4e950'), + ('\x9b9c330be4cf41e8c398272c8b343319c47d1630'), + ('\x9ba3907eff95df554e404e93c1e412d97dd538de'), + ('\x9bad3e5b47ef0b259c692370189397fff897bf0b'), + ('\x9bbd36529b67cf1f834d0673bfeabe02faa0ab3d'), + ('\x9bbf8a99b60c76ffe9ac7817b1087f9010506d18'), + ('\x9bc5e22cb0424664dc5bebce2beaec9f8722b390'), + ('\x9bc7f7c994c5d9b66cc05a0e91a7a7352f530376'), + ('\x9bc800958a421d937fc392e00beaef4eea76dc71'), + ('\x9bcf27d432f0e5fc6e3cfc2784a3da85e22c0dbd'), + ('\x9bd279aebf1e381370ad730cab4f69ec93f01a5e'), + ('\x9bd500e5c21ba48912c4f2e6f27803d8b3411c77'), + ('\x9bdcb28e8382a2c8d100559f99a7552d58eda579'), + ('\x9be29fda0251122500ae0faa9ae708d1613dd48e'), + ('\x9be4811df0cc565b4a2b2ab3948fe5e632531825'), + ('\x9be63dcd1a197ebbfa24621ea1640b6b7dc70d9c'), + ('\x9beb4d288acd1d4812ec72d291a268c2d2d30b6f'), + ('\x9bf97a7912238e0b267cb72500d29a5281b39ebd'), + ('\x9bf98159a284b225cb49f60ee3c4b1cec783ef66'), + ('\x9bfce994612c568ef49d18e367a09253fd7fad52'), + ('\x9c016a3d207bb936a9563a95a12c852b5f701307'), + ('\x9c02525c4266a5c2076b36a2bbb4a906e8c4b3ee'), + ('\x9c0b21a55e3f8a1145cdda5703347c36aa39e335'), + ('\x9c0e34c16b198d88ab8964705ca35b56a568a3b5'), + ('\x9c0f31bc98feae2c7f28d269f959612bc02fd775'), + ('\x9c0f99951d339f7b380bc8f6447a6bb78cbfe998'), + ('\x9c114a23344982be3c49cb44c5067f074151d352'), + ('\x9c1499fb864df839be5a73b4aa3da021433205f8'), + ('\x9c16eb49bba9c937f426355d4c2df0d56bdbcc19'), + ('\x9c1864f33d67dbe768eddb74ffc5e979b8527084'), + ('\x9c18dd578a7abc8347d4a8528d60a5074fa2f1c4'), + ('\x9c1aeab8c23858cbc0c68fd5f1c5016622f35a64'), + ('\x9c1deb553306a0d4eef17cc8ea3ca72c06bb19cb'), + ('\x9c219d73ffcb4dc8f9a5685cd8a473891dd8296a'), + ('\x9c400cdb94848312338a5dcc8ae46b498f64d3d0'), + ('\x9c40b567aee6bd83e4f5145716fbfffee9933760'), + ('\x9c4100761805c8f948eaf909efb4caad6fb8f544'), + ('\x9c45073db1b2355351f9a0dd663f0e9ce8bead32'), + ('\x9c4558d327ae2dfb89d241cd0b4853dbfff99ebc'), + ('\x9c488853b2068e8f4e94427bb79c4d89ab93aa02'), + ('\x9c4889f32f9164102201d2770a0801c7a9ee4f12'), + ('\x9c4db6d7a490dd0aad9072918a62258e4a732307'), + ('\x9c4f4fb5920eb14fabcdf24ad42fc4f7d11324d9'), + ('\x9c505548f2900ea32a32038f86eb9cde6f32e86b'), + ('\x9c5809cdba3650a6da40a5b63a2f7d27d68f7eed'), + ('\x9c5eab85cc957fe79761e8f653902018e3c99818'), + ('\x9c673f86b162121cdd983351e2ed607b7fd6ec6e'), + ('\x9c6997db26e5b80a90a3e6b3692d8d356587ca1d'), + ('\x9c6dc7ecc4fdce20593bc6ff8b0ac8a85a9d8a16'), + ('\x9c7ea6594bc736082290b61741a2a69f7f748662'), + ('\x9c87bce7297f825f5035cde4a012c8e73c8ef735'), + ('\x9c967d555f060dd411020e40443abf84eaed2c5e'), + ('\x9c988135854009b65be0e5eb7b72399cb475a0ce'), + ('\x9c9c4104367b98951a9adf09dd66f59f69d8fc6a'), + ('\x9ca35b036633bc99874f009973eaad1935eb5633'), + ('\x9ca372bb8f1e6c9b9eec3a3cfe87eb2e9a5277b5'), + ('\x9ca6f1701f495547c0f484fc41a6edfda930b387'), + ('\x9ca9ce67390093a4e3bdf26a4faa0afe884b102d'), + ('\x9cabb020d98a45f084b4103aafeb12b095f763dc'), + ('\x9cae728694a91dc76968e3419035d5de2fcbf0e5'), + ('\x9cb0eeab69dd00d71c991a296bdcddecaf9bd616'), + ('\x9cb12b94b3c89e95a79ae87edc44d904c8e472ce'), + ('\x9cb34d852677b24ebd52d3078407feb32fb78b49'), + ('\x9cb89736e8e7f0df0c6c30a104b1c3254aa6558b'), + ('\x9cb8b0c486dcf40d43beb718a985d0e412036067'), + ('\x9cb8d0c499546a304e46f39d107a17e563149407'), + ('\x9cba0eeebe68d98512360cc182e7955ab36964f4'), + ('\x9cbcf6d19e722388f7219088ce480ce95c2e957f'), + ('\x9ccaeef75247fdc68a89997504d9b6485534afea'), + ('\x9cd06eb3e94f05bb3e579c88bd013b457ccc6cc6'), + ('\x9cd0944922c7f7d93acfc6fe52ebf912895bcb30'), + ('\x9cd34ebad329510f92ce294ddb7cf95dfb06de0a'), + ('\x9cd6aa4a4598a7c1514e1d1847e1c5b4879d303b'), + ('\x9cd873f20ed9d161967d78204968f04daaf47621'), + ('\x9cdab2e7de2d8c0b1d80a8af0bf8ff3ce5cab251'), + ('\x9cdc78cc1ed57c7e9b5979ee1cfa803afec1bad8'), + ('\x9cdc7ac30e5ee455f6f3aa4bec360dc2ae26bcac'), + ('\x9cdc93236bc6495e52e670cd7f690cffc1b08a3d'), + ('\x9cde065076cd1a0f261dece32ff6804e44cd0aeb'), + ('\x9cdf9a50d517e77a5aa802233eb2b386fa5b1559'), + ('\x9cdff51fd73d7f45a0651ef7ad1924c2fa7e5af3'), + ('\x9ce4112d8383c7e54dc4c7c57cbab89942fb1b87'), + ('\x9ce88ceda9d842af4c40d7ea20d66b5eb32fe853'), + ('\x9ce920071e5a8390a29ab84aae45c21a3b465b29'), + ('\x9cecbe12e9b619995d642d3537ddbd87d1ce993f'), + ('\x9cecc1d4669ee8af2ca727a5d8cde10cd8b2d7cc'), + ('\x9cf25a2e81c6a1744e5b6313e9cef5f426d95a38'), + ('\x9cf3e60b91a10bbb0867d71484ca33c5f1b45482'), + ('\x9d076827d425f0dc175ebc837afee40d489fb31a'), + ('\x9d0961cd9732a5557a8088a300488469fffda0ac'), + ('\x9d09d31bdc9f6c0adce5ed75d3b457bef9703c6f'), + ('\x9d0b5828b6e4d8673a86d871a216d198557080d0'), + ('\x9d10865eb2fa4b593628ffb243b47d3774a5876b'), + ('\x9d1eb9f9027656c44052b1a726020f45515d1af6'), + ('\x9d1ffd485f387bb050c9e029a150d50ac0c44e12'), + ('\x9d367957939ce8f7c1572b9e8ae8aac05d09135c'), + ('\x9d38bd8ab45cdc3428523c4ab0cc92d666a31679'), + ('\x9d3fb12581160208954e653782400498bb69bacb'), + ('\x9d40ae196f6f03c2eb0a7ca6860cb051fe3a4eec'), + ('\x9d45523eb5ba55edde79529c13835a6fb42719fa'), + ('\x9d4bcd95f9a802bbf00e3a24dba6d01f8cd30a5e'), + ('\x9d4c5c11e30687de29ab1f7f0c5096fb39cafb8d'), + ('\x9d4c6305290836574b63c55b864699a81124cb39'), + ('\x9d51589ca0b33e4cc789e999ff132777187557db'), + ('\x9d52bcf50f08071321f05b13ccd054af4dece988'), + ('\x9d56a1ecd55b818210715cc41168ad716202f5ed'), + ('\x9d5a5a48bd8d233b12c030687d89a217f4885107'), + ('\x9d5c5a56654501b6076aced477632a42254b8aca'), + ('\x9d5cfae6e2733cb026b8f0af901ea694655cd09e'), + ('\x9d5f527692af12a8b3332f685ede913f83e4973d'), + ('\x9d60d17da436f77a6efd12ec1bad17fc8a8ab115'), + ('\x9d6468d2aabd3b7d70b9ea2249175bfa6a9401ab'), + ('\x9d67508c3a7e46dc856f2b7625d2f73cf5d86dea'), + ('\x9d678187adaee0b961fb0b20e4dfbafd560b99ac'), + ('\x9d6b7125252dac818a8ba9fe24db705a97d4d042'), + ('\x9d7392e2a78601181d9facca2303d5de2613d64b'), + ('\x9d7c51b0386274f6a2920bac4ed802a77a8bc1ec'), + ('\x9d7dfe65534ad4163351ad019c31150b36c84ed1'), + ('\x9d8056625efb82ff2a27817b3c63a609444a81f4'), + ('\x9d811d6c8c89d4e0db0cdd6e02222de1dac5d018'), + ('\x9d8b93ff7a363816931faec40e6039385a53a8b7'), + ('\x9d8dc395153531fdb833917ec3d46d85270d2b03'), + ('\x9d91a16539270f8718a610e7338dfd846d62ed13'), + ('\x9d92f4f54370f017a459b47cb9c73203f2601281'), + ('\x9d9bfb295a10974a65ab5f70c08c79bae02d46a5'), + ('\x9da85ea9cae3941879288c05588f5ef8a34f86e8'), + ('\x9daa09e7ba1566370b1558dd1eebfe6e246f36f0'), + ('\x9daafb71c4ca1e00953e24e1a83de0de42a90d1b'), + ('\x9dab2b8fe3e4a06ae1c0b8c788eb875d7ffe3b77'), + ('\x9dacb12841ed1722dc0e6361430ca5516009b383'), + ('\x9db256b4bd8365cefe311000e2db6bb22f2d0c7c'), + ('\x9dc361a38b73cc705788162be773a5e7619e985a'), + ('\x9dc36f66ec851c21de69ff363060ba41e3405ee9'), + ('\x9dc9f758b45801fe2e90c4e7d09898bf4e6d0461'), + ('\x9dcdfd492969949311db5abbe4aa2c487711b8fb'), + ('\x9dd5edcd43ad7ed6c36a8e585f5fc8c932cd1a75'), + ('\x9dd67b804b895dad867c1dd66cecf62d9cc18471'), + ('\x9ddf12474603c1ec322989497a2e64bd01016841'), + ('\x9de36fe552f5c73863bd7e3b0c956769abb00e36'), + ('\x9de6bcda6fce4edbe1a227ce3ea44ecc5d50c909'), + ('\x9df07108ef704fcc1e16ab496c20bc29749d4834'), + ('\x9e0304e0277767c52dd6fb95e502e3957ed270d0'), + ('\x9e04779b45ddb9a143fde5b92dcafeae7fe0827a'), + ('\x9e0582c356c921bd73f98f907def7aff787b0513'), + ('\x9e0ac16cade829e877275ff57af8ec4a38cc3a80'), + ('\x9e0b6c1b1772eb96e621a146551c4d9b37b019b6'), + ('\x9e311fa69ce14c10d7b95d3c12722203c2bfa73c'), + ('\x9e3b0a9434c570e2aa5d95dee0afd669fd67d0ae'), + ('\x9e3df1fbde3d9c37b3f55c119268c6fa2d28dfd0'), + ('\x9e4410d9daa87ef7d3e132761d347cea39d41dfe'), + ('\x9e44f198e0dda6477898c94f2a21ee3066294420'), + ('\x9e464ffdc2be91d9a1d1ef8afc917d90fcf63f94'), + ('\x9e4a677cbe646b9899eb86a27ae4d3d59a775eb5'), + ('\x9e4acb20037c1bcc8c8f74b36557d3651baa41e3'), + ('\x9e557e32bc9cde1f4e5012661cdd3cf78ba06682'), + ('\x9e60b595878a92f6484f6621b1f2b8fd6a4bba38'), + ('\x9e6174d773c30755abd10d8d5b7a0a8607fac8da'), + ('\x9e719b65f6dba9d1c2fe007664a2e2ff928c808a'), + ('\x9e748c7a9dfc4f16510baac00a66289f0816fa9c'), + ('\x9e7521314de60d75f72cd52db789572f6cb5bdef'), + ('\x9e78fc6a1c5d11e533b19a4013af2bda02eed09e'), + ('\x9e792e1d325fdd22adf0b8bdf63125d089e730a4'), + ('\x9e825ec86ef2abd32114636f9252f165bb1dc64f'), + ('\x9e882802f249003737a052e91d5eef43d270ba27'), + ('\x9e8b29c37c1519e0d6f9d7bb1600bcf7a153443a'), + ('\x9e931cd9714d984037d4dfb5dc51fc6c0aebf863'), + ('\x9e96cc205d3d50ee75b9969bd0aeb4b465ee60ca'), + ('\x9e9b46298d51c4520672166ceeaa1c1176066f01'), + ('\x9e9b5a1d5419f28fc6b827fedc34a1abcfe15901'), + ('\x9e9ce077820c8b46345d9229fd03f3a8642e4d84'), + ('\x9e9e1963a5bbcd09dfce5fcead738b3deb4d4876'), + ('\x9e9f502344eb5fe6739e1aa3b6fe55e92c2d89f3'), + ('\x9ea46c8191bf6d6ee730369397ed7cf9042e41da'), + ('\x9ea548cd99e60cbbed577f191e4bff626f34d2a6'), + ('\x9ea8e93bc6efdeb932e03703b0263be06a195d87'), + ('\x9ea8fe6fc17ed32100b2a1d869a05ece226b170c'), + ('\x9ead052ea4fedf023afc82a36cb3bef42b6fcf64'), + ('\x9ebcc9c76413637978f430e9f5c3827a55d7175d'), + ('\x9ec0c01aa785930925f45fe59b5cf67e011f79d5'), + ('\x9ec3fa7d25351981cef4fcf36e8f9da3b388512a'), + ('\x9ec61901a2bdde78361abb5305410e22ce7639d3'), + ('\x9ec88afba8bccee797b0c58d338dd2842547b3bd'), + ('\x9ece409a79ac2b553edf6d6e6c7d0b2f04cec569'), + ('\x9edee89a334d0345b22701c1ff0e90fb26c1b99d'), + ('\x9edf9cf4d04da78f42534e89f0502ec76b389d2c'), + ('\x9ee446c1ca5b742c8e8879dcb1ad9eb072abe0e4'), + ('\x9ee86a3bfcabff17d185c02ddd24254692c8c5fa'), + ('\x9ef50c9dcd56f3c9979875a5800ce33ccdd330aa'), + ('\x9ef5b1e9951093e544c048de5b882ecdb996b343'), + ('\x9f0065e0cfff4188bc45791385bf64167774d380'), + ('\x9f0295ce898524e152de69657bd931a62267fa06'), + ('\x9f0724ecf8dd80d70c5547ec38de4da2cf0512f1'), + ('\x9f079d8044a1f568886a18703cc44e2c054b76c6'), + ('\x9f09d082c4f58ca5ff12e30ba0ba43f50a3295b0'), + ('\x9f10583e8cf6a121bca7a399459d393ebf75ff33'), + ('\x9f10c79dbf2291d62594eb50218adb3ce873c79d'), + ('\x9f11c82c9ecaceb98847ac0fbb981ba3c4c81ef6'), + ('\x9f1b0a074f54050a74586496b1af7e3b40296d72'), + ('\x9f1bee762201afd2941407c361b84b4ee54b33a1'), + ('\x9f1c7eaa6c9a7013b8f3aba566569b33a00dd1e7'), + ('\x9f2446712d3addd3db17a33597f2db7a8df7ba58'), + ('\x9f2d45359e6c11a9e933c6c1042d6e0c19f1b3f0'), + ('\x9f4555a992b2e5a4e4672e05b5b0ff6c25730102'), + ('\x9f49ffd869194a92c2e71895169488cb4d037a00'), + ('\x9f52433d5b6433b9593d14d3cf80c7849e4c10fd'), + ('\x9f53e76498963d696b66716142a629b1f4a76acf'), + ('\x9f61ce4394e589f642fb913beeb824f6ad2f6f9f'), + ('\x9f69beec22147fa08a15627f2c437a1da1232071'), + ('\x9f6cc1656e629328ab26db9604551de24e193a08'), + ('\x9f6ccbadc5143e16b7e13afcca215d879ca63a76'), + ('\x9f6d2597cfbad7c8f8e1d34973e072df76a57320'), + ('\x9f738b1b3dbe3c31eef45efd82f511b6f245654c'), + ('\x9f73a54f9201d630c5cfe94f854afe0973cf4101'), + ('\x9f7650834983594bf81af031c2a48483349b0915'), + ('\x9f771a3b236ffdc8a2ff7af6528deeb8c0ae9570'), + ('\x9f7d72cf49d9a744078b81c906e9ea4b0365aece'), + ('\x9f80ef24eee60fd15e826abe2b85927ca7ea2119'), + ('\x9f830926468e6110087438fb3ec6bd1f2145c62a'), + ('\x9f83a13f58fb793ec1715deab60d2081148e855d'), + ('\x9f8d1419da89454fd3353eeb83b533dde607c735'), + ('\x9f97d3c09eda2c2bb88940425661fb59fda88601'), + ('\x9faa975c66248be2f0d2f60a5ffe388b65065f96'), + ('\x9fb1b53d9ee1f031e90788f3bbe4bc2cf2bf66d9'), + ('\x9fb4573ab0f520801367195e486ad1c21c9e5536'), + ('\x9fb6f060f2544b54e77716015d5779b2b811faa8'), + ('\x9fc086917509ed42a39b5f7c2d3cc96d2e7d8bd7'), + ('\x9fc2497fc02a04dfb24f429175c58db1661ba256'), + ('\x9fc56d4c87332db02e948f036aa9059edb18bb0e'), + ('\x9fc9c87b35ae8e0ded91e6d6f6688a511667a2bb'), + ('\x9fcb11c9eae1f4298d6c923b4390540b44979524'), + ('\x9fce75e4e23c3b39265437f3cb6b91b0aa85ecf5'), + ('\x9fdd016d6dffef7a769455c625d0ffedb4b938a1'), + ('\x9fdd804037b87e41cea55a4f8cf403fd92f5a8bc'), + ('\x9fe7d7ea40fa05b89c81dcf5d75991fb249bada2'), + ('\x9fe8bc40b796eb33285be5d26f6ca733f9bf24d1'), + ('\x9feb258ca72a1de64ac75e1262023fb95663dbc5'), + ('\x9fef76451c617412d030c45ffc179ba24b3b6cee'), + ('\x9ff18fecdbc165d1f780c8117115e3d69599ffb5'), + ('\x9ff38129d8d350df00ab827a1d3bdde9eff67a14'), + ('\xa002fa0e9125df38dce0ad350c6b8bc2ec8ad781'), + ('\xa00ef3af474b09f08be9e5c58e0e58d19ddfe917'), + ('\xa01080a460cdd95a7b39c12b8f6ad30fe2a1c523'), + ('\xa010a3019c81f46f36c300801dcd10b77c201049'), + ('\xa014767992c58e0875d94de99db3ce6e1b7cfcc7'), + ('\xa0186809a4c1ae0b6c46e56b81c4b7d9148d787c'), + ('\xa018a0c3178c949675f57c0c44d098598acc1a91'), + ('\xa01c8ddf6be6856808d59a3107cba6c26ecf647e'), + ('\xa01dd6c0892e7fbd68fa12a462bcb56b1fa2f388'), + ('\xa01eb672ff5c85e911152ff947786d38f73563c2'), + ('\xa025f29b58b64ea3252f10295202a836bb173b1d'), + ('\xa032bf1fb046d0f411bb2b54b0bb2751537e862c'), + ('\xa0380e1b10a492541096d698aad8312d1f576e2d'), + ('\xa03a730637b223dfaab34df45760eaaf69635173'), + ('\xa03f3c81db700e0589ea327851c806abf63a943a'), + ('\xa0404fdc4aa42756408af5f4a75e056f429f7d15'), + ('\xa04242b9df8cf56ab78f94c3649a099e02bd6c85'), + ('\xa044fbe5960b015ba4e2d988970fdc3ce869b588'), + ('\xa04960ba36efd768f943e88497f2b8a537df6366'), + ('\xa051b112d7e4f5c40826da00c6504766902ca056'), + ('\xa05b4027705c96061f7532e9c096bfeae190fdc9'), + ('\xa05c20db511be11206421e0a1ddc2a78bd6d7fcb'), + ('\xa05c5596ca5fa5d7930d94e7eb7ab7b8b2e1a618'), + ('\xa05da37fc8ef9438687ed1dd799b9e6d7dcbabd5'), + ('\xa062069806b01155daba35916ef890b7407a1a41'), + ('\xa063c22856a617619c2baaf3b77e4d09f69d1e44'), + ('\xa0654c31042377ccf0b6ca4576a9a6d8d22284f7'), + ('\xa065b266f7c302984e903994d12d72c491480a22'), + ('\xa0675aafe0b085aa1ce6407b97d98e63f3da7ddf'), + ('\xa06cb72729ef581512758735a7643c60900b7e6d'), + ('\xa06f20101f1274fb09ef84228d071fc0e728e3c7'), + ('\xa0725a09a57fa10bbf78bdf1e7d0ecd7c6d5409f'), + ('\xa0732d981f3452d9bfeed7e7bda8b06b36f5c224'), + ('\xa07866089154dd57646e2936da4a09d1a11f7faf'), + ('\xa0850caa5567904cf2b908bbdb2681bbc2ef31ac'), + ('\xa087c3b7a8906229388910d4c6180ec5902b9c16'), + ('\xa0887daaf4c57b25e074e8034af0c1b3b1a751fe'), + ('\xa08e0a57445eb880f537e4aeff70b945289c7830'), + ('\xa09231537c62db8a9c01499950d34566bd2bf7d2'), + ('\xa092583662f5ed0768b7168821b37c73396d31f9'), + ('\xa099312f58e8adc076799f45f00699408020fcc2'), + ('\xa0b483c526d3199308c4b10adc88c297edc7da95'), + ('\xa0b990e53b3a33ba4ef76827a4a0deea2061ffa3'), + ('\xa0bcd01bd9f647e7dd064942bb5bb98341add79a'), + ('\xa0c0ca49e31d1da1e47b68d4ed788df907cc16cb'), + ('\xa0c3bc7cdefe7e957678afee37109697bba4edcb'), + ('\xa0d58e1954c687029db4192620210e0ecb2867e2'), + ('\xa0da5f199bb9b56138a4e528fce40d60fa7db8ed'), + ('\xa0e21c740cf81e868f158e30e88985b5ea1d6c19'), + ('\xa0e2f1eb22591f02c458f37313c78254c4947bd1'), + ('\xa0f4f97ceb6af7945e6bcbe347d9e4f39b870c15'), + ('\xa0f6222f01dc6fdeed085a6c88b3afa6c5112b2e'), + ('\xa0f6f6797b8981ba005c6bbfeef72816488fe7ee'), + ('\xa0fb977d3b840aacd70b38d52c6f34cc8ae91735'), + ('\xa0fc7778a0d9b09465e236751bdf96bd92949301'), + ('\xa0fdaf83c3eb7237380e3072dcc14ec9bfa72d96'), + ('\xa10b74c0c63a7948a23666afb331cb6ba08537b0'), + ('\xa10f5e4a4742b20733828ebebd4dbd9afaef7f67'), + ('\xa10f60ae2c93060f1e3a8fe9e52ae475b52bcc30'), + ('\xa1118009c52e2ac55e2b4c71ab279a1ca37124a0'), + ('\xa1151a58ab5271705915251b82f21e7370c0ac76'), + ('\xa12267ee8cb87e18a138d29564a0376a653f670c'), + ('\xa1229f23e470c782b7022a4ea04eb89fa90ade0b'), + ('\xa124e095cc598a3638898f3e51d3d93627516e6d'), + ('\xa12d6b37b965af366f0609d8f1c3bcb23e58f6cd'), + ('\xa12eb680ef22315b49053471dc4fc6a641740611'), + ('\xa12fb92b16206371ed5f8e3fc58ece258e0fb3f1'), + ('\xa13e68fbb268fe610f35828b50239ed6b78c95fe'), + ('\xa144902b49b3d356486fa5c7282ea99488da7fd9'), + ('\xa1459068f44e3628827de72d6dc1343deeff0bd6'), + ('\xa15be1c6be22eadce76c42c4075570f5618d31b8'), + ('\xa16205c1d26fd2ea242d2020c7ba1a82c8689754'), + ('\xa16b1b06124d799689d816952e5a03b72c53a723'), + ('\xa16c65797e52ff8d14909854fdc1c5747ba48d1b'), + ('\xa171eb27f53a7e340422b331da34a7d104f3e9a2'), + ('\xa17af06bcd89873a171bf90be6303790862798de'), + ('\xa17c56537bcb93f24d96ea7fbc34913fe92812a9'), + ('\xa1849699d156c7ae4636927d151d30eed04ddf40'), + ('\xa189c673751e6bc70ee3d55c89ae4b5cac337c20'), + ('\xa18dde7f9c01d18648b5a7b9025a268c53c4b678'), + ('\xa19d7f84c87f7ca15df1341e183014a1badd4e99'), + ('\xa19ddb4d13e74aa8245959d03ae8b8379ffdeae8'), + ('\xa19ecdf589c963571f339506b79fcf6912a889b7'), + ('\xa19f5ce1164b6ac55a3217ee007a835b4b6dc26b'), + ('\xa1aa84051a261136d4a48bced616ad4710639857'), + ('\xa1ab8bf14702de16ea757b85afb80bdd8c589fbf'), + ('\xa1ad2f461671fcc575c39e10350fff32cc2b352a'), + ('\xa1ae9afb3a9e20ff40ecaec1c7c2fbd129c02a01'), + ('\xa1b0c352ab3f5ef2cf3c24ee1221bdaa30cd9b28'), + ('\xa1b697d47375f169d4ed9ae3ba6630f3d97aceb3'), + ('\xa1c720c8fbf5754f2aa9e94d99fa4d6fe49ddec7'), + ('\xa1c84fa08609767a6ad29a6d57c2ec433daa735a'), + ('\xa1cbb0ce766ab028f0dbecedbd123f27b74886d0'), + ('\xa1cccdeb7c13387dc92ed3717f794b84fea9cc03'), + ('\xa1d48618b715a22b7a2ed18eec1392a4784cf42f'), + ('\xa1dbc78cde93306cb88eee8ff2312ab7e380cd3d'), + ('\xa1dc41b03825e0df2c8108a4aae73e0cf8868a5f'), + ('\xa1dcabbab4075e4fea4fa2b08b974791379c5ccb'), + ('\xa1e0dd8a8d35404fed747a2575525a3be039598f'), + ('\xa1e9b965ca3e5530d17946fc4499a1903a7693c7'), + ('\xa1ea5a94d7a4f1ac57e1f2a9a910e402ccf9d868'), + ('\xa1f30403ad00167bd72fd6b513e17eb7b6bc1f97'), + ('\xa1f3802b6c9ff55d53e8e5f4a93727ea8cdfbbd8'), + ('\xa1f3d5b952664d87d20c429dcf4c787285aae191'), + ('\xa1fc0b5dfc305eff1b6a7de5f749449aaab7f321'), + ('\xa1fc434070307af34a6a2ecda137cc37c8acf3af'), + ('\xa1ff5be7a51f7be2128bc5a7ae5ea91dc0ba25a0'), + ('\xa202882755aecf03413570f60450610de979f03d'), + ('\xa2099b03f78e3ab88aafbd27a0d4231cc4a7b120'), + ('\xa20af4226875e48e2d401cf1ff8ef98cc07cb08a'), + ('\xa20b892622a206adacad7bdd244716b145cc8768'), + ('\xa20edc8a03552a014a8ca8e50aa08f22113284b1'), + ('\xa213824d415ef4f6e0e4466370893d7c0283029e'), + ('\xa2141ed9d996275ff6db1df68532405ab4593274'), + ('\xa21f92f5e2ac31ae2f077429659b64d0f2c08a63'), + ('\xa2228926acc82996baa92f31a3db6a81f205f735'), + ('\xa223d5e2aba85bdc91c7443c49a9613520aca12c'), + ('\xa2240fb98991bc15e9d1ffa9f6705d91562fd696'), + ('\xa22fcaad7f40ed111311204619388b0b72442675'), + ('\xa2339ed23ec54accee26e0e36640d3bc4e58199a'), + ('\xa235ed5a7c35cf84c103043aaf1049e9213766d1'), + ('\xa240d516422b1eeea3207d8ec103b89b70a36b23'), + ('\xa247185b38de75f9b8380673e42a5f0b84f9f05c'), + ('\xa2476db10ece9009d734c18e86937eafcb98ed23'), + ('\xa248966916a3e29e1ae7d4873fa825b2f596adc7'), + ('\xa248e70c15ddfb4ec158bc9fc7f7cbfbb1e6b42e'), + ('\xa24c50e83705dfdc1a4dde94154298b3bae3d00c'), + ('\xa25c6051c3f025097ba996e3d29b672ea3f81e82'), + ('\xa25cbe23472a6d2be2d55dcdb1774c3880c62b22'), + ('\xa25eba79f8eca40db98ec7a713e5d4de4f9881a6'), + ('\xa26b1a356fd0c434dadf49dc8724db839b031a8b'), + ('\xa2704396c83685514377d372ca39538b7e86675f'), + ('\xa27622eee5c8e7f66557de655ad0c471b3cb8a03'), + ('\xa27e8b0fff2611992cb1f5d3581030b36c66938f'), + ('\xa285048883fee2ee72b69c7f49e3b6e18942bf20'), + ('\xa285e368e36afcc32bef7a8fd51e578fb1bf844d'), + ('\xa28b7c6f297a86c1c98d87c69500c679519c5075'), + ('\xa28dcbf179da12523eac53b2334c41b768a9155a'), + ('\xa294531581fed4519c19e10c801db6a6f9b32487'), + ('\xa29868f5eccfe29f4fc3634f49633f6a268ca421'), + ('\xa2988daf650c42b19e027144fe767b9ecaa805f0'), + ('\xa2a06a3f68398336196945d640fa63ecdc56c28d'), + ('\xa2a9f3f9926c72cf6fe2527c92b114bb7bba8d7f'), + ('\xa2b2de6e93f4cc36e9083a34addfcf4addc7919f'), + ('\xa2b6b2d2087f2cf88742ca224f44f9226090b827'), + ('\xa2ba7d606a9ea8c432ed80ba1d67ad8511a0b419'), + ('\xa2c24e6b7f8595b7cec7e41531c1f9a3255bcda4'), + ('\xa2c7233410f1cbc0f36a760a4781b0c7a195293d'), + ('\xa2ce47d70bb28531011aa40016e0355556899780'), + ('\xa2cf079a3416c84e9cb76c3f4deea93f0b302e94'), + ('\xa2d4799206d22af62157682dce05152d29326698'), + ('\xa2d7dcb64ff4ba6bee6de949d71b90b133a40615'), + ('\xa2de047a554a10c604803140812fd728c32bd7c7'), + ('\xa2dfd9be4e5bfe50e244e5c9edd5497d20a0b378'), + ('\xa2e0ee43c2374165c78caf691154c68b5aef7fd5'), + ('\xa2e59783f193773a896469ad43609842b1e2146b'), + ('\xa2eb00951c0634b6720a569789f8568d67fb211e'), + ('\xa2ed3e49153300d85b0a8c8e08a5bece2fd48d14'), + ('\xa2f0a89f442079bd5853540c824765ead407fc33'), + ('\xa2fbb50c2ce5692c0c60e97fc7a27f1ac70e6dd5'), + ('\xa2fdcbbf1f1382b47a944473d5c2f18fd175564c'), + ('\xa30376d8cc2c020abc9ab9df089b06b98ad1a126'), + ('\xa3046413fd69a5b5a0a843418f7ccda388643011'), + ('\xa30826feded5e60c339baf6c3cd44ac6ab5c2f4b'), + ('\xa313b2c59a7f5506dab06d7a81a1313161e8f3ec'), + ('\xa315226d7c31245e6a5e4fa9a1db235521b9f6c1'), + ('\xa31a7083dffd27c0b99903c83de5e09cf6b48b28'), + ('\xa32c57d7348912766386eb4f48a786c780504723'), + ('\xa32cebee0289239c91981e351519e9ad33a2c9ec'), + ('\xa3305b8180790de0633afb5b9c2084b3149c33a4'), + ('\xa3322f85c037322bbae160b10cd31a0559ccf73f'), + ('\xa33377fec3617168c988062167bc250783bf35ae'), + ('\xa3387a16f403c9da4482138d611393378f418e27'), + ('\xa338f0b6c65e6a34ff8bb084848dbe6c192cf1fc'), + ('\xa33b1666aba55b5792dfc2f16c6ee8999edde4e8'), + ('\xa33c0c121a4d1de75f2235bb12af3b40d3a10838'), + ('\xa33ed48145331c29edb11633060e077d6809ce13'), + ('\xa3448348bba406ce26fe36672a843ef0726067b3'), + ('\xa349619fddd2d91d1eda66fe148f33aade3c65e9'), + ('\xa34a09b41423d85d8d3384ccb99d65f82ee6c3a0'), + ('\xa34cca3fe58e518094d3f6f007c15bb68cd23b37'), + ('\xa34e2bbc1b897c7f7ba815f7b11592f40c472424'), + ('\xa34f9dac45332ba229036e2c4587be35506bd0c8'), + ('\xa3514f4648852904fff8e838ca65b3b1c3f50f80'), + ('\xa351513f13eb11be75e56ffedca990795d8c036a'), + ('\xa35222f93a32170a54ccf2a02deaf78ec534add7'), + ('\xa355d1a5e39a64db9e35b67da52918ef7d217c60'), + ('\xa355fd65eaeded60362aef1e507235a24f132b25'), + ('\xa357d3e55c6e38e797f588c90fda101bf1d76f2f'), + ('\xa35d3cf19119a30965b1db767e693619c826e93f'), + ('\xa3611e76a14ed7d7f8792897139f6d4816485d9b'), + ('\xa36e0d9775a0d536beb84306ed55fa28e88ebca2'), + ('\xa36e14d21798a404a1ef487ff275bc4ad6e5c90c'), + ('\xa370644ce1a52a56f58c2d993cf6afcc5d1a6e35'), + ('\xa374eef9b7bd464414123af2d8879c05dc8a21d5'), + ('\xa37651487fe3386adc2f8281e5546e661c882269'), + ('\xa37a047d992764e7367affef4847660e3c4dc5bb'), + ('\xa37be8b5c8787563b10260522303d98155b2de22'), + ('\xa37dcc06ab7d0835c3bf2496462cfe8f11644abb'), + ('\xa37f03b4d29ead0248c332d137037d699366cdcd'), + ('\xa383738da39a2b1edecd68d0013291ff61df5cfc'), + ('\xa385b8ed052f34b35f9eb6a77d03fe994292082f'), + ('\xa3875193ed57b89b0de54be7a60298fd2f660ef5'), + ('\xa38ae7a84d5063113ae421abcddfd75c75b42959'), + ('\xa3926c13fcc77f2fa613ed1a6b7330c2fbcec185'), + ('\xa39efd6166f5450b2cd30dee3fa8c3a4a1bcbd7a'), + ('\xa3a9565261825c48b2e483cdcc99b503c90e9484'), + ('\xa3acf98685019a765550e1e4a6d7035c8601c26d'), + ('\xa3b08354190a305bc5a332abbf5d0b386fb0de54'), + ('\xa3b317410489c0c0e2b91c2506b6398b7ebc1d36'), + ('\xa3b3b50a7779e3074f61a2826608f89152e902e0'), + ('\xa3c435e15d52f090a6a11748ecce12ad967193fe'), + ('\xa3c45078b81c8b96f0d801efdc42a80efd5dbd64'), + ('\xa3cbf49997429cc38ed0fa993191e789c69a97e1'), + ('\xa3cea4135525b575139e5a571a602309c10d90bb'), + ('\xa3da0dc8c938f8aebc4a03b9cff258ed957bbecd'), + ('\xa3e2c4091eeafdc493c4afcbb892b2da2812f61d'), + ('\xa3e3abf440618d11f1856c3ec3e99e2353d26bf3'), + ('\xa3e4f063ad001c6eb739a288151299bfb263465d'), + ('\xa3e8b20a1ddcb2e08b9ff3fb2c0b310831698f89'), + ('\xa3ecab3ef5b6bb898aeb8e0d2f212559eb9a25f8'), + ('\xa3f1f18cd57a040246689a901bf4be89fed2264c'), + ('\xa3f205604cd471e898ec811bbac3cc9c4cfd346a'), + ('\xa3f68d9f2cfe245a2dd95dee7859f917c90c9b6f'), + ('\xa3f7f5bb68145c6a715dde110a538b357043f669'), + ('\xa3fdf660d0b6c8a6c426267ca52abb82ee5b4a86'), + ('\xa3fe33cb210176474242d94cdb576d73feb4a0bf'), + ('\xa3fe84eb8994afee1df4af94b8a4034cb82a5c9f'), + ('\xa403e1ff1079295c124319a57a20a18b55aa9d26'), + ('\xa403e420f466febf11095d1963d11354d3ecc44b'), + ('\xa4043d55999581af15ab7176e92fb38089e4d6ad'), + ('\xa4073e0d5c28cebdda17efb136364b7753394c03'), + ('\xa419f91b8de6545de3926fbdfe51dc84637f9d66'), + ('\xa41d5616c8255df32e491b7e46470e95715fd416'), + ('\xa42089bfa530797e5ee64e4054564b40951d22c0'), + ('\xa422728d14e5c082e8208246cb514db9037beb55'), + ('\xa428e4a116681e9fcf65e4da5cfe8306d4bafea3'), + ('\xa42f8a1bdc164b9814c7f4682269b6fd95eacb65'), + ('\xa4381f53003b35e84631bdcc030726b6eba831d2'), + ('\xa44fc60c91c38ba3d91b63be06d1f0b843c36e80'), + ('\xa45194976b1cb46a3f36d66722348f749e0504f5'), + ('\xa45443cb49f9f79cd57515116745fc035b0bd031'), + ('\xa456db6b6c82d7237a49a8928210e468586ac9a7'), + ('\xa4591d973b888042e7d1f1f3c22ec84b61a311ca'), + ('\xa45c4054f41d58128b8b3798137e2eb4efaf0338'), + ('\xa45c71d8f7784595e7d37983ea51050acadbf414'), + ('\xa45e6022d862859396e8e75fb30d114990796d04'), + ('\xa462c5053cd2966290e1334c2d4d1360d6f56d95'), + ('\xa465696acd088951d084d69b95fec3ef5db1f8ef'), + ('\xa46a71f7630489fb4200f7d6164ec5f175d3b5cf'), + ('\xa46c943850a8f941b0230d281d57c51e81e5340e'), + ('\xa46f0566500d00fd8aaa9f9ae99a68a7a79ae969'), + ('\xa474cbb101cb99c56cbcbad6c258ce9672f90741'), + ('\xa47a856dcbcc6f031f1d5603aa7230cc4457fd99'), + ('\xa4815a6f69d2012ce9b337721df24602312e411d'), + ('\xa4912c5d987062934e75ef6a8203964862a2e8fe'), + ('\xa49295e136a80e9313adca9b44ad5b3046c25463'), + ('\xa4930869ce821c49e7851b3ec15558ca8c911777'), + ('\xa494f1f12c904eefbee8869e7b83152c05f5263f'), + ('\xa499cb6b09629f3968e2bf555ce1e3934426a079'), + ('\xa49eb356a76f38b5fe2856c4d91b0f666a5454bf'), + ('\xa4a2fe3291bc0c3ee16e212856bb69f89df6f9e6'), + ('\xa4af6976cc24c21cbb2a2b3cbab173c15bcadb96'), + ('\xa4b7512c7bd1d6d5e2e9d5d0f66e787ab048eda4'), + ('\xa4c2e2db38a0edea0805da30c7a3b32e2598b131'), + ('\xa4c5796365dd0f3b9b1fc4fd948bd2a46864e885'), + ('\xa4cd0443afbaff0d48b5b70487faa36d9d924af9'), + ('\xa4cd4ecfa8b8cc00dcaa6630b675a2d1c6434e9b'), + ('\xa4cf9429fd29b823e9ec391f387e64aa91a5d764'), + ('\xa4d1d9e2e377a1877c8dfa35ac93ae6ff60282e7'), + ('\xa4e656380614ce51d60cb773c8f8668b81ba797e'), + ('\xa4e68f31fafb37341ac22ac66bb4276d3f5950fd'), + ('\xa4f07b193bd6cfc4291a359ac80f9f6bf258183d'), + ('\xa4f0c4a1aa7cb76b834272bd8740dc0fec3cf593'), + ('\xa4f28f8f1722522a6aed907d0e501dcb6d55c039'), + ('\xa4f4e2992d4fb67dfa08edd8b659e981444df746'), + ('\xa4f8a202e04b202f3f22a6fe38eedd6c134ced5a'), + ('\xa501747057c09766af68671d44f175c3ea7fe185'), + ('\xa503092d4ecd7c5ee377ecb70d13a69763d091a7'), + ('\xa508343647ce9ac468fde66223d31b464e845d9f'), + ('\xa50f305fb2fd47b1c30a04de7fe7a01d44d7d7cf'), + ('\xa51742f9795cef202dc79d1c3381414298106e1b'), + ('\xa51b0b268f41bcade2153e95377310996e022406'), + ('\xa52031feb3a08608ea62c6f2ca2cb302dc147c36'), + ('\xa521fa7bedc6dd35d2c0bc9f53362d74b9040141'), + ('\xa52288f2cf655ef6277cbc1b13d22c7c8b7a992a'), + ('\xa522ad93a383c86922a28b7d8eeee1ee43eb390f'), + ('\xa5295f13cd182fd239a43063b872b8836558d703'), + ('\xa52961c0c3a97488af9e5b82dc8ac16509bbe3ab'), + ('\xa52e564f9038307c751d54758b8ace9cc12d82d2'), + ('\xa5309ecc7d4f8fd82da52f4ba5f11ee96f7e4e4a'), + ('\xa53775c3def478ce99d46d778aa16e42ea46851b'), + ('\xa53bf9b92cfeba6ea3d5bb10bb012af0a1776387'), + ('\xa53f804f90488f9742bb2389022d6bac4c9354a9'), + ('\xa54bba6152efb8299f3fb701a2dd0f78b7f29958'), + ('\xa55ba0c8634ab4cb988931d1b0fad8026aa006d5'), + ('\xa55d6d7b484274c1c3f80e679fc003fb5181aed9'), + ('\xa5601050c89e88cc27fe99e8b274738d0181e394'), + ('\xa56f1fa5ddf408b863fec053dbdf0a96e52a5fc9'), + ('\xa580c135f4a331b722eba3fa7e6ad7d9aaa1d2f3'), + ('\xa5811f42ace6fa16b8c4cbcf4fdbf2add5d75ab5'), + ('\xa584ed9e261be83d8d0b8f6c3119e88f6196d082'), + ('\xa584ef98291c10a0ff408116bd964e78085eadee'), + ('\xa58bfb07635ea84194b6e5b2f9a18035fe6cdb62'), + ('\xa58da27791abecf514f53269dc04e297a1301c79'), + ('\xa590f71c2d5595ea367d3332477ae433afb4e23f'), + ('\xa5953274b632a9024d6a55182d1e974b55046593'), + ('\xa59d7d3b23d32e442a861953ffc877df29e64691'), + ('\xa5a26615606ec90dadf8c13a41cf86a42691c629'), + ('\xa5b23f90821bdafb9de18a1905b588f4e9efce01'), + ('\xa5b39a9c40f10e562e83c2ca54a93034fbe9a251'), + ('\xa5b506dbc894520d3626cfa52d0c1b06a99f5271'), + ('\xa5c5de284601564bd7cb0067c4ff3d918aa88e96'), + ('\xa5c8812b7f8053e250aa2c30dbc6bfd1e97ea134'), + ('\xa5ca4ad3234f85b97318c432c8e0cdde68ab7679'), + ('\xa5ccbb3edc7d2b2d016fddfd14638727bfdad802'), + ('\xa5d02a1404c39f3c216477176a2a565d66b3d1ba'), + ('\xa5d3549d9f1b3eeac2c33005a99a7772b134a03d'), + ('\xa5d92af9d69cb3457dbcf166f753976ea397c441'), + ('\xa5dc2f8d3597c3e1797defc90234c7b35b5a7075'), + ('\xa5ddf46c7609388e98784404efe26127cd8ce209'), + ('\xa5e15f78ec08beb6a91bba31bb0f369ef249750a'), + ('\xa5e1d6f5cc241cc1d304b2e8d5c0d849401e3f15'), + ('\xa5e2bdafe7a52a7f02f3d7fdfa495f409b1a0b96'), + ('\xa5f32bf14cae935cadffa3359e4f4f3565a7cb19'), + ('\xa5fbb3f0e0b12180b61c1035aeee330c42c0957a'), + ('\xa5fccbfb6290c5f3aad580a20f8341eea17738cb'), + ('\xa6037e6865db7dcd220a631e7b9f4a8eb6417c42'), + ('\xa6076024aee0336fedde3e5b0f5dbc77d0e46bac'), + ('\xa60b41cfe5f6713cc35a924f9af99b4ceb09305a'), + ('\xa60bcdb4e7e70378fee884472ad8ac50d0e9f740'), + ('\xa60e084653c8f5b85f9ba86dba0f39736ae13821'), + ('\xa60ecac49ce707cd3e520e6d40960234c62bf791'), + ('\xa61025dc2964f6cb483118544851005c41f60ebe'), + ('\xa61325d01bcbda60dfd4f27a4b80139035e34924'), + ('\xa615c0514da5a04d6b0c470b007255de6c9200d7'), + ('\xa6181c7a3a3165c8e71d4a55e030b0a83881869e'), + ('\xa61b5cc833b656e83a36bdce77e82553dc5dee70'), + ('\xa61cb3cdfb3532f530ecc4f3d5f41ed900f79d92'), + ('\xa61ea123fafb4ff234e8055b78f6c914306db6d4'), + ('\xa6213170b070351fc0f92523fbdb92be5a9bf8f6'), + ('\xa62ca688b3c8e7bed5e21e5c6c263cc478ed7566'), + ('\xa63493adb0a51de0c65eed4b331455067a6a2d8e'), + ('\xa638b211950ee71de6c193b0638217f1d6a0b602'), + ('\xa63c58cf87cc18bfea56b9999652bf377c56691f'), + ('\xa63c9786b3e40d3477d40682a5a91ccf147b9f6b'), + ('\xa63eb1be5182d815d40b4e990f76f11f92122220'), + ('\xa64130a2b466c8e9429bc522d35f81df6475cfaa'), + ('\xa6438df96b5cbbd6dc0700e05ce3d99a7b60512a'), + ('\xa64437c3947a63a65ab286ead009ff754a7dc903'), + ('\xa647600dc642d04a1bf97a33ee98a900c0282ed3'), + ('\xa64a6dc4f050476e84d79d84e55d834e8b8f6dee'), + ('\xa64fa9ae43a8c93dd61914f1c15861b78f6661dd'), + ('\xa652c1e65182038c446220fc24d1a15d3ccbc493'), + ('\xa65798af1d06632a9b4b0c83a07406b51693ebf5'), + ('\xa659aeeef04b8ad056e6dda82ae25be1d86b595d'), + ('\xa666d89c239b91c14678c8a55c9f81402859ee06'), + ('\xa669da2c6eab8e1b8e4b3439399c7c0125d3273f'), + ('\xa670e14265a806cffbc66ea3be0083e14369fffa'), + ('\xa672ed5ed650f374f4e54da3edd20870425d7221'), + ('\xa6822b21df47952710face1a43784afe33cb4124'), + ('\xa689da649fa7fba235c689bea2a480ab372b99ad'), + ('\xa6933c1ae161e3f9e4e4abf8a393253d18d02719'), + ('\xa6937f9c34ccd1d00618edf6211b7c63b8950d8b'), + ('\xa69726333173d31fb2fbcea5aa9693069292f9cf'), + ('\xa69a5f2835972f6730a178c66057e9e94ad76f2d'), + ('\xa69c932617b12bdf29751febdfb93966108cadf3'), + ('\xa69da4635eecc52adb4e965e2ccf73a9aee891c6'), + ('\xa69e15def383d5c7a8e9ac1e69cb23b777dbdfe7'), + ('\xa69f2135173adc0d2e56d192531d92c176ba97ad'), + ('\xa6b0ab6e88a95779f1487d3362acbeb912c3d0f6'), + ('\xa6b15868b8c1fe859ea39381789992665e86b967'), + ('\xa6b6643d0c3a479c2c3c0694e38482eb68596164'), + ('\xa6b7a03b9f0f8d6a5c505e017bd1ea92a36a552f'), + ('\xa6c11d9069b098eb1992a954a51ef8d581387771'), + ('\xa6c98ef4f05f533403bbee9bb55652110a4af8b8'), + ('\xa6ceabc836a9c32ab96d3ee0d7ef058b297e725c'), + ('\xa6d153653f35a79b63a1cee27d4bb82a481909e4'), + ('\xa6e03a603acd7a8da9905d0599a7dd1aea3f68e5'), + ('\xa6fd675bd1a8ff42294ae47a9a12a43098363fc0'), + ('\xa7087a5a94d0e424d41e4e76b910fb31e496545b'), + ('\xa7091e51fee566d9f067cfada5d07d5521d5fbe5'), + ('\xa70b4a6e3ad091b2e35d1a664ceeac600189ed16'), + ('\xa70c0af1f800eaecca1af43fa8737f5d91f1d514'), + ('\xa70fae6161ee1706640246e8f6574f608d69d52a'), + ('\xa7146e8aecc410898529c6adf6f8a9b34955b38b'), + ('\xa72000e6f97740a2749f34646173823e846d342d'), + ('\xa72141bff83d20587345ddc868d8a9802d193d7f'), + ('\xa72ed630a811db95367859e03982ef7abd3b360e'), + ('\xa73036e72e0a268b01264551fe27dc8932158129'), + ('\xa73281680f5ad76f6c836360aebb667f68c34681'), + ('\xa735ed563703a9069a8dadc130af2e1944a14086'), + ('\xa742833dd6d699f66e3150a408577ca3e89a50b1'), + ('\xa744d2b052c12c4711be2267797014d92f80e54a'), + ('\xa74ce3ba93c95d094508b830e3db772e98227470'), + ('\xa74d086ca66d68fa4425bb964231db27726b03fd'), + ('\xa74ff0ad0e1990b0125488fe3628b9f023a07fe7'), + ('\xa750cf5b20ce4a24e940ecf3018482eefd8c8e91'), + ('\xa752bba44bb5f9c9f64059c950dbd52ee64c0ce3'), + ('\xa7561f797be0fb9c54220d7e93eb0a952d28fa03'), + ('\xa756e0ff87b6feb1d00a668ebef7e0bb3734c64e'), + ('\xa7600c3687bb4580a809b67c3e3372c50f34ae6e'), + ('\xa7667283053e632d6887a44158d6c2898d3333aa'), + ('\xa768d68fce65835e09d8feffc49239eca66287e3'), + ('\xa76d286d56f35fd258a58c0b9fdcf24a970cbc8e'), + ('\xa775ffa8dc46b3ab0aa0249982595c93a2cb78dd'), + ('\xa779045e37268cf14096866a0e69596fb7298b70'), + ('\xa77f46764200611ab2f5966018a25aef6c9d703f'), + ('\xa784e3e3b1b1435b735bd95aafce2bd4d7abffc9'), + ('\xa787e604ded042d9ac3a6a3f0b6b8ac6f870f3c5'), + ('\xa78c9110bf0bbb50d6bbd5a7d4b764e6fb2092fa'), + ('\xa7908e2f26698b49ec26649c33a6f73d11363d7c'), + ('\xa7964f8be62df0ff2bda2aac3870d3c4ef0a064e'), + ('\xa7992a58142ab0df70686cc4f1891b4719ba8b0b'), + ('\xa79ae1cabc5c97e76aa4c001f8b5caab51b7281c'), + ('\xa79d6b84fc67703e4c12b2185d74777ba0df926f'), + ('\xa7a0423fbc88cb15916d71c275df0ad8a255b1ad'), + ('\xa7a10ca71d68a2f7adf8417471e488c2eccf41ec'), + ('\xa7ab773eb7dbb216fdd691b99e84a0523ae39d17'), + ('\xa7af2225554771551744e7045d978cd3421061f2'), + ('\xa7b5c10cb6fd8502a9c70df93c09a7729b1103cb'), + ('\xa7b6c6c12f4c63ada213ef6cee34489d6799bf0c'), + ('\xa7bb3ee0faf1c8897a7d2b413992209bfdc61a23'), + ('\xa7c0993b58ec2d40a8e13139ee04d4b51be2d4c4'), + ('\xa7cf90d82d3bb2d54d46a8919804054af8d4180b'), + ('\xa7d00ff90b8ec0cd99f909471a29d7254bc10bdb'), + ('\xa7d828180d8338d0b73316f371b4da4b9c0e2e00'), + ('\xa7e05558b3c73740ebcc7f9382d50ce4dd72c75f'), + ('\xa7e578ffdd9a95500df92defc368a3a69cf9b891'), + ('\xa7e57e690d64c2a51359171ba4fd32c86217993a'), + ('\xa7e8a7c00185bfa72d7f64a0c137423a49817ddf'), + ('\xa7ec096fb568c434e2ed349c04706da8bc92be2a'), + ('\xa7ecba5a3a3d5b65f685d7e7a70a604ebd5bb0db'), + ('\xa7ff1033be340d3c3c84b672bd885b582a005ef9'), + ('\xa80857f814fc9e3d5c729722b0e9fbaa8e4a93ee'), + ('\xa80b7d6ec0c0c92e2ea7d869706d6f5ea7f7cbd6'), + ('\xa815bd37499139e9e48b5074b4716342d9e3a1ee'), + ('\xa819d2bb3c1046a32edfdbf63283ba34b24c5bd2'), + ('\xa82277c21b766ffbc2b8a2c1e69448e4cb2475fb'), + ('\xa8284f0d928baa51ef1e191037bde2a96b7707aa'), + ('\xa82eb30e4128d4db4ed04aedf92fdcb2f16ceeec'), + ('\xa82f53f45201d925c889a054b1c83e7fddfa1de5'), + ('\xa8351673f31079374753eebf71f53749f06d8386'), + ('\xa840c156d311f5e47cc3af651771cfd4b535e2f2'), + ('\xa845beddb069b715b3ed08b333a58c5e039d232b'), + ('\xa8471cb55cbf0a14b8eb0ef4ed64a9d04f12bd51'), + ('\xa84c3956623012f5422a2c395fec0f32123d8910'), + ('\xa84ffec4b9aff31a0eacf375fab5210d2957c7bd'), + ('\xa857f334235f62122388dfa762e91d66d3843dc0'), + ('\xa85a1376eb89ccee7f8ef4d9de27e2b670b53ebc'), + ('\xa85c52ab329011976f25ecc95f031d174d086d94'), + ('\xa865e512245ea368be448c3de93f145726345ea0'), + ('\xa86bcf2425df49de23bbd009d83e50bb99850153'), + ('\xa86d288c41f69d10f389b89d94ef1c2f81d13a94'), + ('\xa86d978902effd0ec73b8e0f9983976cb17ab134'), + ('\xa86dcd9a3bb824ed7d12e19b64938f6a3d169bcd'), + ('\xa86e5ac4dd2caee8360c222d9b9acd45f6d681cb'), + ('\xa86f61677ade270982ee623e9236782604afa5ae'), + ('\xa86f7305dcad67a4eae871f7d6738d59110db137'), + ('\xa87059a4897576ff7249d7c14bbcc925df76d4e3'), + ('\xa872483c4ffeba9e661e07b30100ba4010052e59'), + ('\xa874829c56a6fad24ab9652704a610b065806eee'), + ('\xa8837992678b1cdefc84a2f077deb38251e7894b'), + ('\xa8898d03949c5c65cf2cf555892841a807e95fb8'), + ('\xa889affd4784267a93f0868e2ef943f121fe9b91'), + ('\xa88a1d1feecaab61682a374861abf8fc54632877'), + ('\xa88f02808e7d4ddde5cd9de0eafc981487b32067'), + ('\xa89212182f1a3f4744e4cf4762f376120467d8de'), + ('\xa89a51d674c88691e7bc65ebaf3b0998e3ccb2d2'), + ('\xa89bab849e6ec940b3b84b8496e2c2e9852c5b9a'), + ('\xa8ad35a7a3f16f986a24c349d0e0fbdfa3485794'), + ('\xa8b1ed1f14e36e5627ffb7218ccc0b75ecc3e5af'), + ('\xa8b7559eb1fa7f5dd1e792d655b7ae0df364d1de'), + ('\xa8c6fee9e1adae1f806345cb37bdddeb346a575e'), + ('\xa8cc8883af55475d5418c700592507b37a02c209'), + ('\xa8cf6b2a424d62ed63993caeee893e370307b43e'), + ('\xa8cfaf6e0d3c4fc81484b0b6c3192638ccd79768'), + ('\xa8e122ca2d4d88fe0e9da273962c65e2a32f9b9d'), + ('\xa8ec70c6f6ba48ac4fb4287758c1b7a2299ff700'), + ('\xa8ed414ac46f0b89dfce217cbe8ae4ac082c1d16'), + ('\xa8ef44cec3a6f8b9b1c83a230df2d1fed9d2a3a5'), + ('\xa8f3b78ba6bdbed3881f6135346ea2776125c770'), + ('\xa8f504b885641f6eb57f24fa58e038458fecdcda'), + ('\xa8f70c2092de9068924ebeef6b64f3b34011743a'), + ('\xa8f7d46c5463807f369dd8b0e865b2784efa8610'), + ('\xa8f805e988d14e5a9af33d2958a00b2d3f47b1c1'), + ('\xa8fa9c64bcc479932346af0709b8f14d633ade39'), + ('\xa8fde67ecce53cdae7d43a174b0be268faea0fde'), + ('\xa9016577a1fcff1aae4cfcd93ff80eff10562cf9'), + ('\xa901fe9a85ced2d901482efe21dcb69dbfe7f926'), + ('\xa9069ddfff6a3205f251aaa4741c3c3f13a9b5c6'), + ('\xa907a6143a4eb8ef253dc6d56a61cfd4113d1cb6'), + ('\xa9080b6ca206baa0d81984edd2d40720f92cb0cb'), + ('\xa90e4c91df2df6738638e18d4796520954c82748'), + ('\xa9121be6c194636b57b5a65a66496f692ebdab6d'), + ('\xa91394989208288ebca263dda87f14cd1d29d8a4'), + ('\xa91541cbb74301a15c9fa87537741b6e567cf187'), + ('\xa9196254148039535aead8a870dac05281ef91f4'), + ('\xa91ea4faae06cebaeaf4446b85c28dad431d394e'), + ('\xa92dd0c98e55c67feed9e114c2994469fed1a4b0'), + ('\xa9391b4c41800baf09308699e23f1ce10f32be85'), + ('\xa93e1f1d106cd8ce283a1895368be6ed42207ab1'), + ('\xa940c438761ad61872cca848b3ab6603225c6794'), + ('\xa9485dcd287af1c137ec6703ad7eef4f991e9af9'), + ('\xa949ae431ecf2667440d192db5791def2ae65596'), + ('\xa94f19845b0cb7a941e00c58dc9a502b1e086d38'), + ('\xa950365d5a10e3409944170c33ec5dafc5b64c87'), + ('\xa954df8120c52fe813e9c7e59a4c5a6b37597b10'), + ('\xa958067a86f46b4b82b6b1ec43fa83f5875f3e43'), + ('\xa95a6c6e2605af1a1b7a9146a145d458c4f2ab3e'), + ('\xa95e641ccd111b91ac305f818c73bfc7a3f22486'), + ('\xa9622850c7900649f4073c9f6d327b5c88357ce9'), + ('\xa96856f3be68b53b7795d15a1ef4ef2d69fc92f3'), + ('\xa96ea26e873f7f6edd95fc5994041c229ab8f860'), + ('\xa97456765eca940f6c8bd9e5b59e911afa338782'), + ('\xa97b35be56bd91dd1475d5cfbd9694e90ec177a8'), + ('\xa9803ffb4627c7363cb2faf9eba82860de6450cb'), + ('\xa9833689e2d6c6578da2670c56480efffefd8f20'), + ('\xa98ab960505db5e2a387a2a8682e0737b9f04d95'), + ('\xa98b36a20731e48a925a8fb7ec818fbba7809004'), + ('\xa98c18daaef61813b02a0371cf97c6b1d443c707'), + ('\xa98e1cf4950218117eaf6a0668824ac1477dc8dc'), + ('\xa992bc66be412c3b3777fb1d7569b14f4932a651'), + ('\xa992e29c5a155bef9de00d63034a442b3f8642b3'), + ('\xa9950d1131e69e7650cc1b95e571c63b722c3d65'), + ('\xa996042003beda6eacb8b7a74ce5fc9623872e8d'), + ('\xa998feeca74e8d111f9fdcc5e11071678a830d16'), + ('\xa99d0b8ea284b7ee95147ed10897d74a012914ee'), + ('\xa99f20e740b55d33e79ab3a63d6260c73d99bc73'), + ('\xa9a3b7d84f337c76778c906c7d908072f8842eb8'), + ('\xa9a40134dd0b588f2c130972bde9a76fd7394dd9'), + ('\xa9a450efc245ce57fd1d1f77534f57b2da3cfb98'), + ('\xa9a68c43ca72ee2295064434075a6b33fe310c94'), + ('\xa9a6b32b9d924cb87106ed1521794c97bd53f21d'), + ('\xa9a918ca9c979d94b9684af9663ff4d365e01f22'), + ('\xa9b745d7db34d71cc17388f19f34ab23fb3bfc77'), + ('\xa9babf149cd7875be3011e4949a567671231e5b8'), + ('\xa9c0f1d2b3537cf0d2ff07366d64cef42130fade'), + ('\xa9c1ef395d30ab4cf85f33323f73554a8d3d27f7'), + ('\xa9c591247be8fc00e769f6c1acee31ce680a6ff9'), + ('\xa9c5b9226d6aabd9faf62a2e1cd1ac6fb88f4b84'), + ('\xa9cc3f0e53c593be86219eec1323d29bbc8d1ca8'), + ('\xa9d082cf8eee44106ce172eab87a05ba61a82a35'), + ('\xa9d119f0e8d6c77c147f9e58d059617845ecbf83'), + ('\xa9d1b2163f7c21bb28bdea37b3e90b0b443725f2'), + ('\xa9d1b408f7479774a6ed3b26cda249b867be3e33'), + ('\xa9d38676c6022aa975ee4f5439ad6c3e2a3631f5'), + ('\xa9d780c7737c287fae1a472cf1bc8946cf951ec2'), + ('\xa9dbd90230cb080aa7a839f26b23d8ee82ea5b17'), + ('\xa9dc1e3e21e578b1c0a0e1e40962435748b89636'), + ('\xa9e7890658b2a38ed29e0a0043024d36d272d580'), + ('\xa9e95fb67f0fea4592ea6facaa417141f092142f'), + ('\xa9eea97ebe9f35ea1634abc5f61d2a84eef5132c'), + ('\xa9f0cb2d8f47ffeab9db1d3a5ccb2661417e54cb'), + ('\xa9f5c27b96cad7234806f02ec18e1527f2acb42a'), + ('\xa9fdfe1db4037cedbf4e23113bac26819cfc8591'), + ('\xa9ffac86b919d04868cf36653f247ea0ae5f0e8d'), + ('\xaa0414d1d64ccba6a078229978dc951f620ed1e2'), + ('\xaa04d77d34ef05edc21c423230afc372c1f431b9'), + ('\xaa04ff6f8e3a023b8a416008b20116e7fdbb3b33'), + ('\xaa0adaba72050f650ad4ca2654e779deaa8f6c3c'), + ('\xaa1522be154f1372a89ef8a7e2ed3b2e83f74d57'), + ('\xaa1bf224218cd9cd3745e08d7a5e0c02a1b0d125'), + ('\xaa2e11a5c713c93ecafe742f7841c42405c7b51f'), + ('\xaa2e9233f4c9e771ffdd89ca60f93be4551a1a41'), + ('\xaa30519166cd1798c222987cd928942dc42c8017'), + ('\xaa3b33da4363e40f35b6bdd5d41e87c4e1ade867'), + ('\xaa3e3e8d97d7c2dce026633ca7937cba9aebab77'), + ('\xaa417f0f058538da3e58877a4e5e3ac14995ea36'), + ('\xaa45e6672b85e1b2982f6bd55729e9936bfa557a'), + ('\xaa46a7ec0c56a1e304f5280e96fbdb685907379a'), + ('\xaa47951d48d51e5d82e097742b997c5240eec651'), + ('\xaa4859018f51cdfd9a7844276301c1addc708e50'), + ('\xaa4e615c2cf1df7b40d15413be950f2c6161b116'), + ('\xaa554e553eccfe1ce6e3e8107f09b433ee0561be'), + ('\xaa56c3456a1a7b1df0d5fc1a939d5602081297aa'), + ('\xaa5a7f350b2a0ff977c7819ba2cab1f27b088fd9'), + ('\xaa5b1bad662d6b996f5854aff428433e453d9be6'), + ('\xaa5c1d451d40d7556e40193f3357b714875caf68'), + ('\xaa5fb67b23c40b864897751fb902aa218b2630b1'), + ('\xaa60e5be4edcc98e1ab90056e7d5c6bf2721f664'), + ('\xaa655517872b60986b27601f6690f5c6a1c698bb'), + ('\xaa6eab687b67174afb8a1f4d2f331b68819931e1'), + ('\xaa7359782fb8db14cf6fb991146e710ddd34c80e'), + ('\xaa75161232eb63dc2f220ade7e6eae954e4ee418'), + ('\xaa7fce47e58d17ba912773255245904a8681d28f'), + ('\xaa80a89e8f91c18d6663863451c1bdfcb4c62225'), + ('\xaa869f0bf6e087f9ef996c4fb0a423a7a6c1ec69'), + ('\xaa8995f11439a27c62b183cc4b1c620e79004a70'), + ('\xaa8e0e22994ed2e06b4072303034c50074497d2f'), + ('\xaa97a31ef4faea869a03456b2e6445332891e277'), + ('\xaa983a420704c19d8ec2041f971733db2550e722'), + ('\xaa9a7b3df9e604bd88421546b9423906dba62c87'), + ('\xaa9a9a58ed8f91a9684d7d409b61bc73d622fb55'), + ('\xaa9eef011d16dd077048b93c1f9740a735b1e8e3'), + ('\xaaa0479a6be8023bf29093c2a75f0734b5b9d402'), + ('\xaab82869068b602df73088f881b5a0d0eba50e90'), + ('\xaab85044a6cb764c07d350670ba6969c33b3b953'), + ('\xaaba33848990e8552e8d208d44e7e0c96398ca08'), + ('\xaaba9f6708d5d01e74995df98c29863bc65bde34'), + ('\xaabd1432d8adef91a799f8f79583e01e5df63881'), + ('\xaac6586f309d9a6140fecf29b2b6c488c83daf10'), + ('\xaacbbe23b5de5d185473a99b2d9a3bebcabb0709'), + ('\xaacee8c957c0f8ad025160499634d008ecaa25c7'), + ('\xaad3b723651439421e61d214dc8dccd7edfff618'), + ('\xaadce86950ac60bd12b1f485ea57195020bf6b0f'), + ('\xaadd83ae033ceb9e10018daca3587c8b17e56b92'), + ('\xaae135b5628564cff428fbeedbc219f74d068490'), + ('\xaae2dcafa122c19fe54dc4aafee5f9997d1a0e39'), + ('\xaae95180cc71c3ba73d7616d8436100735d8932a'), + ('\xaaea60ed0429c174baf0622ae4d4d089b5e84dba'), + ('\xaaeb063a243f09edfe9c92bce25fdfe34aad8e02'), + ('\xaaeecd3955f0e22f9dce0676e459655d9e953a48'), + ('\xaaf252a42dfa5c9c49379711d7a0e8f5504f445b'), + ('\xaaf3193476cbb33bdf917741fbaab6c17f874c5f'), + ('\xaaf472b78fd07fedfe318625b54adf3478fa5ec6'), + ('\xaafc5bf5146ca276a73ff8cf4b051c7a7b5baf2c'), + ('\xab01334c90fa7ea68bfed38e2a3b5bc29aabb8e0'), + ('\xab027585f13fb67a6863af3658959a317154f94d'), + ('\xab07a6b3d2c7613e7b47b11f27dce5c8e8a0a2db'), + ('\xab0c80770b4b1fa75549f3777f447fb2e067deb5'), + ('\xab0ed6591e28a4da25edabdfc776ff64af0641e0'), + ('\xab0ff6a8c12ada0ab5c8fdd1c7ac275f9d658f8a'), + ('\xab19be1146d1209c482f92787365e0313a0a5c4a'), + ('\xab1e7201fda34bb3ce6fed04e9fbc99187c5ea10'), + ('\xab1ea883df98c566f057d26938f07d17c656ad3a'), + ('\xab20596b479c3e1d4c0797be03661bdb6e00ea71'), + ('\xab22b7dedd5112cedb18bab4c268d31c45e7a9b1'), + ('\xab25d643d3e681f2576d59360fcbd8f5249cc2c2'), + ('\xab314550aa764ce1eae75a8cf8921f9a77b25f54'), + ('\xab345c7df6a3f24ef9d0cac6c1e6d6bd8cfad329'), + ('\xab3cd05689c5d01777e34384698231d43bd22dfd'), + ('\xab4096175f8af14ae9cf074a467cd1c49a6c7886'), + ('\xab40c51dde895f6e0a4f4af8d8273e3cef11a1a0'), + ('\xab4841454dae6e35cb6fda83e0b3ee6495111a6c'), + ('\xab488738d74d590883a54342ee93538f05cc92d5'), + ('\xab4c9decef9f9d86e35818d129fc872e5eb70072'), + ('\xab4fde2c9c2f06789b0de15e924e4591d3fc83d2'), + ('\xab51117cc0cc933db6f489ffc2e5b10dfda4d055'), + ('\xab51f37ef6b12d953cc47b5266a1cd9ba5d2a460'), + ('\xab523d8c11b800fdab24257fd3247a102d7f0a0f'), + ('\xab5424508bdb27e4e07edd8db3c2aba0608e58be'), + ('\xab6680896e480b4ba823f363da832b7b61a19a0b'), + ('\xab6c130294f0024b4723ad1f17348eb20641d074'), + ('\xab70c5c01b189db67006787174800c24023a56a6'), + ('\xab797d01bfe6215c588ff1e91319d74dc5326520'), + ('\xab81f1f7dd7ef54f234db055203637075fe553e1'), + ('\xab8ce3726085026e301aeaa0f16867808d89a264'), + ('\xab9130617152041e50f639aae521b4e50955993d'), + ('\xab9478db006cbbb18e7c1069fbfb27865a4c7d45'), + ('\xab949ceb54610bbe1cf722ae90250644245f39c1'), + ('\xab9bb52999fd80c87d17fa58f8e909307749c693'), + ('\xaba01765905c8a5a3c0a00413e94d17ce2df191a'), + ('\xabac5e8530c0cc2575d01382dfe689f0601d82ea'), + ('\xabad32ae4a0543ec5bc527a318c0b14fa75df30d'), + ('\xabafd78135c41c5481f9804e9ce207e5eab5a16f'), + ('\xabb1251b0a464dc2822f21e0b9aa70d5d74af5b8'), + ('\xabb47c1747706cbc9786d04b5fa322da9a9f57ec'), + ('\xabb6f2b96d8991211dfaf2bdb6b0bb7a84a9a4ae'), + ('\xabb772095b78228bb1891a0d053bbab76b25ef2a'), + ('\xabb77b0bf965252c244ca92564737009ddd3c3da'), + ('\xabbee376f5e89537bd84b717bedea78c64ddb464'), + ('\xabc1ee9032155edd7c034f3a293c322f9e197bef'), + ('\xabc4c949a090f2bae3e621ea423a17ffd2ab1a59'), + ('\xabc6efa9709020b45c16aae1e9d6188937dde6c1'), + ('\xabca895c6010ad45fe82a3514ef406c84e4818f2'), + ('\xabdaa5145e136bf5c2fe2308bb0af4f38e7ae501'), + ('\xabdaa87717a73eb929009fc0bc9bf7e83d66833a'), + ('\xabe2871ea12998618ebb3dc54417a51d59ae21f7'), + ('\xabed4e5736ccb149327c2d8af7254cbd44174e77'), + ('\xabf35a77e2591ae3e1c4e3e88718da5210e086b7'), + ('\xabf560d76980248169aa7fd68128b951f280a674'), + ('\xabfa0eb32e0771fe649feb25e36c527e6121a8e1'), + ('\xac0231d3ab337aa2e43e4fd4962f641ee9360ae5'), + ('\xac09342b317a45279e86e9a6f7f15cf787401ff9'), + ('\xac0c46cabcdb24fd0a3798b546d7c86d0c37d953'), + ('\xac0f9cd8bb6bc3926597ea1cdef7d248bf1d83f5'), + ('\xac100fd199af9210993c5a0babb5bc1e32c7ecb9'), + ('\xac164cb4820c6a0c9bb47047b37a59e344579af4'), + ('\xac1bcfa29c40d8376a9064b5221d0d8e3409a62f'), + ('\xac1dc9460fa37a410b4e354bc3411225c471f153'), + ('\xac22c9283713eade734164737efc3efeab3966df'), + ('\xac24f13523e442b139121c76df69ae45f093d84a'), + ('\xac26833be7e62ceaa7cea25ad8d4559a50b83231'), + ('\xac27eef0f38e442294c8e9fed14bbffc9943e967'), + ('\xac29d8ee2baf7bfb04e84e5328671a89c16204c0'), + ('\xac31aedeffd31b2ee7fc1d9567e3787d7fb1d8b3'), + ('\xac339fa909bd482ab65653b4dc77a8c143dc060e'), + ('\xac3450c691e0e7e732367644828753125a7e8af3'), + ('\xac3ae8557c546aba1e20c5cadd9629411547dbc1'), + ('\xac3d3dcb17bc094560ff57bcf5775bb16de3bd00'), + ('\xac3ec5c819139775e7ecf9408594dc3048056842'), + ('\xac3f767d9e92802f3a16c3335102d813ada1575a'), + ('\xac4110ef4fa4eab059869dfd661278aa99725a1e'), + ('\xac418b6fb3f50f076df212c6dae176963f3ff699'), + ('\xac421490ec962892c58bd9ef91b46970de316af2'), + ('\xac42e4d9b7941c7e4a8c662e4a5b03d40058071d'), + ('\xac47c48418efca27a9492eef6ba1ad5766929112'), + ('\xac5b1363e89b5ed90f06d44716456bc618f8013a'), + ('\xac6395fd8fdc03006c5c2ce0cf2d5d43749e4a8f'), + ('\xac64abe1cf74c6c6c1ddbf5af8e62b56713955c2'), + ('\xac7f3645cd6f7abf0d63268b2d72c8e42d5cc6f9'), + ('\xac885017f394621bdc808a630d0586d4400a786f'), + ('\xac89f9367e14a556c1da93f39bc901f92fc01e8b'), + ('\xaca6b4058a7bf506787e4be13120ee5b838a05b4'), + ('\xacab8cb1f07039b6264cd20b0d21e31a3c173c58'), + ('\xacafc0014e979d0939665648f8ccdbfa0aac4eda'), + ('\xacb63d25c2feb02d29525a9c3d47745d8c3f0086'), + ('\xacbbf7de1461eff60aa1905445ce291fd0d45606'), + ('\xaccd14343744a7802c3defc6d4e14474f3360b69'), + ('\xacd0ec4808e729e5b7e5b1c2fe20e9e6a0157e40'), + ('\xacd4df77536a3a7f29c30ad039a774e666ffff38'), + ('\xace12a95d2bdb4d22f6e7f267c07a38077673ce3'), + ('\xace1cf26547fb9693f21ba635093aaf5499a3294'), + ('\xace332ceb67eee2050af607d36c7bd6a76ff02ac'), + ('\xacecd80b995b5a1bb14bbaf82c80611c0633fafe'), + ('\xacf012564d34f133d2dd8350608e580eaacb3cac'), + ('\xacf0f46547958a04dd0c82e5c7d634c713efd462'), + ('\xacf32f4a04f50558daf9ae7d8dcbbcc5867c5f02'), + ('\xacfa67c2009214e48d1d71ca90c832d1ade3edf2'), + ('\xad0718896a4b0599801ed9b6811bb072026e2c00'), + ('\xad15bf05adb1e6dbbd49ffb23a087f7ca99b1f77'), + ('\xad16c4a2984b761f5a246d1c9fbe418fa166b7ba'), + ('\xad1a857d140b107b42754b8b66ba00190ba5f6d9'), + ('\xad1ad55c95bbf6e88bb668c9f8d06fef70c9cba3'), + ('\xad1b34c0d95aa085a84928dfc1914bd110527c67'), + ('\xad1e675775eec49f1ec44a80015d50457d0c77a0'), + ('\xad1ee72aac5de76750ab3b0249da0b66f2dafcf3'), + ('\xad355fee5f51bb9a33a70d0a0ba2d4330e1111c1'), + ('\xad3b00f341a1b8330b37eff898213b47e11f2130'), + ('\xad3fb09eec054da19d9357e110b1e9cf908ace48'), + ('\xad410e11302107da9aa47ce3d46bd5ad011c4c43'), + ('\xad42259b4508d64b72b05fab2a99c000a5382c85'), + ('\xad44b95eb695a19f3143e537303e549fe09016aa'), + ('\xad4536c6f688c76e0b5663468053e1a88be696b4'), + ('\xad4c899291f7a03dc3b51bff3ab11d5bc30d75db'), + ('\xad4daadc510d1e566726e71be7f30792c872c4f2'), + ('\xad4effbb0621191f20e10df568919255907e1f57'), + ('\xad5199d34f4f9f08523a697dc099952a2b943446'), + ('\xad52250643962e3f4b66af2e720c9c98f0a15700'), + ('\xad535a4066e78efaa6aa49992df41b76f764c10f'), + ('\xad614783f8b9160eb736ae864a2c6fe6cf7707c5'), + ('\xad622e010145b72603ce967ae886b8396072c532'), + ('\xad715eb65bf52170f66ff243a13b06bd3bb5beb4'), + ('\xad7748aeb5c0de9febbf7aaa6160557a1a9d7ffb'), + ('\xad7e9aec639b66ca203f38bd944aa75d948aa833'), + ('\xad81043ac75ccfa300db6a0ac13c9c39faf82cb0'), + ('\xad859c8a98c339b268129fa9fa8cb8f0ef79e2c7'), + ('\xad85d553580e9579fc5e5b47f82673c025b79a9c'), + ('\xad883fadf398552841ca42e336d8d5da8510362b'), + ('\xad88a34f75da8bcfa038507864e9ccfab0ce6a22'), + ('\xad8d1c85f3b16110b8fd8940685eabd3609bf474'), + ('\xad9f5929b0312a76680900d7025483511fbf70e5'), + ('\xadab9852f6436e29f1447e7ffd236d33e5e3cb65'), + ('\xadb54264901343918606e4948ed212a32600e8c6'), + ('\xadb730a76eebc9532b04e58263c95d3cc361dc73'), + ('\xadbba0070577a555292434ad39b5c326469cb650'), + ('\xadbc1eba844ca26eb8c8d7a6c755e8cf17b7def5'), + ('\xadbc52dd2a1c3871e7e72d4061fddc9c50bd9322'), + ('\xadc0cde336864ad0723beb15585d49307769b5aa'), + ('\xadc12a5b2ead86be838719b5082102e8c78c1d2d'), + ('\xadc301411757cea823a2a9c311c11cf4005f2fc9'), + ('\xadc87bbfc25719f6a823c8307824a09002428d5b'), + ('\xadc939893261ba595e21d02fc980095fe181af01'), + ('\xadd63a553537929a507d4b12b4695bd3679d0cae'), + ('\xadd6b73eda090cbbb2c0469f0c5335c4b29fecab'), + ('\xaddc1ba12913d3fec04e478bad420af0971a0ab6'), + ('\xaddca7f9077e432ca273a9eec34889f03982d3b8'), + ('\xade1b3c196ef5535a534b165909d9d6d2e5a5062'), + ('\xade29cfc20052a017462ac9f2fe4774eb6a9f3c5'), + ('\xade47cde583f2931435075acd4fac6a204e8388e'), + ('\xadea66f2a27ae68c9ab28a82e3e0adbe98cd1f5b'), + ('\xadee480cffbdba12c04c455288027d2ddc025a0b'), + ('\xadf89e633ffd2ede93f26aa9da1a9e61f7aba233'), + ('\xae01ed4618622c61fc1c034924c53cbf71b2ce38'), + ('\xae06bb18cfae32ab15fae27a15740ef6786759b7'), + ('\xae07d0597db81a298d989cfcee4b7ebedea55e86'), + ('\xae0923015eb5847cdf41418f99634b5fbefef5ff'), + ('\xae0a6ae9798d977aab0051327afa158fff712203'), + ('\xae101e53b877ecbebab66fc85b5d6a8f9f8e7415'), + ('\xae17e98d8dea251e01db01406fdfd2a0ea4f901d'), + ('\xae1bad56da116aa59b27ac92fc5445bbbb3fd162'), + ('\xae23eaa2c0edb47d9b262135a4e16ddfc64ce116'), + ('\xae29a27f1c376d9d1bf475cb87f9124967cafdf4'), + ('\xae2b3e3c67131e69149df76a63c9d09bb021a140'), + ('\xae2eb529ffcb43bffbc07fe24244dbad34078be4'), + ('\xae2fc7773edb3bc91f6e13de4b9f1c94abec67a9'), + ('\xae33a4538132c8fc174dd53b3ce771009405d7a4'), + ('\xae351644590557a86d05b3aa623155a4e9d82547'), + ('\xae37d94c400c161596f261e3cc52874aeb63a7bc'), + ('\xae3a4b0868bd2655808bddfa581c9ab94d00e033'), + ('\xae3cee4e80f1aee57cbbe0b1ac95dd82899f2a42'), + ('\xae40de8a3aefe3ce3dc9b6030832b23c4209a9dc'), + ('\xae42814bb283fad77d6270314a8d3a3881ad7aa0'), + ('\xae45e0d05e6e04eeabb00d5eeaa23508dcb8fb59'), + ('\xae484ba86d2ebb9aab3a87c05f2aeaf0a79dfef1'), + ('\xae494d956817e711cca1f35721694053fd9238b2'), + ('\xae4bf4894665f6ba86d1013cd7a831e991c1ee27'), + ('\xae4dcecc8851144fb582c19e05eb2521325ec7fe'), + ('\xae5030d140680d1a9d6c86457a7ac61dcf72d969'), + ('\xae5317cb01ade521bd5b24eab2870d0a2cc41d1d'), + ('\xae53a9feef76a876ce4d7ed8ec26d92a1da35f8e'), + ('\xae5a5508a025ed9fe787f26e9e1416130eb91c98'), + ('\xae63f5c828615b87db6d06227a567beae9b382fb'), + ('\xae656d84b0fe997bc80f4cccda1c858ed8fc16cc'), + ('\xae6c3958d4f11e36248a2eb8ad56f03ee2af126e'), + ('\xae6cc67ccdd059ef87fa377f8fcd9f8ac82f260a'), + ('\xae6e866458fc76d0c77c83af38dafd9528312105'), + ('\xae70c77259f408aec37f0c43584dbe1fc15e1b6b'), + ('\xae7d0b3eda6a639c2aa8421e2ac931602230375e'), + ('\xae7de6ebbe285de5c23bfef43d6bba4e2983b4e9'), + ('\xae7e47e44dca79aa64828f49fbc26beda7f40a72'), + ('\xae83c8ed9582686c8bf4bea2140dbb170cbe69a5'), + ('\xae83d7eaa4b0242a474c6355387d2809d07a7334'), + ('\xae851693d13c3e44a1a1f74c0b04b6600998dda0'), + ('\xae88e2c0faf3ac65b7ea5cd0c9e161621d0210c7'), + ('\xae8a7a256b7eb2fde5cac3d732169721251de67c'), + ('\xae8c6efc43be3214720ef70566b5b75a9168b276'), + ('\xae951adea6f409a637b20670f111c1fc3b183be8'), + ('\xae96ea5348b85bb41a5a590bb0620d8c34ad1700'), + ('\xae9a37bf35dd75755f46a2dcf8db1448266b0e24'), + ('\xae9dfb433324299a7e17efa5e741740f2ea089a4'), + ('\xaea434feec0058f379eda468533a065fcda37878'), + ('\xaea5226bb2704133888c4562171a5856284b34b7'), + ('\xaea5295cada8e7fc70110ccb470771610bf8ebcd'), + ('\xaea836d8e20d19a780e410514543f1ff952a6cc9'), + ('\xaea9717054bd9843b1187423634947a267a6f2be'), + ('\xaeb04cbebdbb29047c8319209fda678a40b17123'), + ('\xaeb128840a19469a4ade95fa1f68a9bab17319f5'), + ('\xaeb775d174b76b01f32d30db738a1c3e73ad59e7'), + ('\xaeb7d656fcb9b6dcaa9e7b4fff11bde3731427be'), + ('\xaebd1670e16b40c5510f7b95da931c111b7105df'), + ('\xaecd58b6a5d33778a7325fb147d87f998ca33ff9'), + ('\xaed2c2df214244a3312b82a476a52b6a248440f0'), + ('\xaed440f979af5afb798077329995db37d5ef8a10'), + ('\xaed6abc39261ba496856c4155e3b3191a47e873a'), + ('\xaed70088e19d5c80badd1d9f8a2c56455322db7b'), + ('\xaed9074197f5941e5ebe4c6478d585476666ed72'), + ('\xaedc5cd5cc2374f0c9f218588a5e8831d19508c1'), + ('\xaedeef6b444ebc98c52f641111d6ecf4f57c5475'), + ('\xaedf8801222122fcc2b278ff424bf1b0ddfeee18'), + ('\xaee6587d184b7db950a000662383c46a263406cf'), + ('\xaee8098676a52bf916c168d2d1049dc0d38bf56d'), + ('\xaeebb1f0989781613a257ae73cd6d38e2db8a36a'), + ('\xaeebd70541de14342fe1b921ba0f1ba917862434'), + ('\xaeebf2b1cde86d7ef3e517fecadf288f1c60bd1c'), + ('\xaeedb3cbdb405ce8b20a66f448816ac50567d5c4'), + ('\xaef2c0abc7f72565ae4ca398a7572361797551ea'), + ('\xaefe1e838fa71f372d28232a7c4237cedc5a367f'), + ('\xaf027354b2e5790a55723f36890f611d4eb93338'), + ('\xaf0c6a9ab8f9685b1876eea1cd6ef46d0a95a448'), + ('\xaf0d1a152948f557689a83ef103bd678c7a00c3d'), + ('\xaf0f13f821e992bea8ebd3f4de534a49ad7a2725'), + ('\xaf17fdecb6a4cc502b282bf6eed9721353e7be3b'), + ('\xaf1bc79904c3c4bf92efe16c544b16f0bfdc19ea'), + ('\xaf289e517c32c52415b4cc7b911bb1286c2506bf'), + ('\xaf28e4aa39da9752db3cfdf53572f8d94081b264'), + ('\xaf2f0147ec4fee665047dbebf5c5405268ae021a'), + ('\xaf31110aad600672367e3e9ed4138de67a720828'), + ('\xaf3a4dfc735328863dfb10c18de5dcac19e86707'), + ('\xaf3ccbc43dd075670e7ea1dcb32255d18e1c800b'), + ('\xaf3d450597411e065fcb47bbdfadd33d5c867d23'), + ('\xaf4297774d952541446e20e081151b9e8b8a8757'), + ('\xaf4489e23c76f2ebcba122f74238892bc6822058'), + ('\xaf48eb2095fb39e5edcfefb3a8c7ec9240d03c42'), + ('\xaf4dcab8c02c6b17d9c0bd347803e8b57c520350'), + ('\xaf4f3776a39a26c8e7307022164eef3d3518f8da'), + ('\xaf4f44719c2b3e935c8e358ed3430759af9c02e7'), + ('\xaf58cca5d6e11f5fb63c3bbd1f31bddd49538ab2'), + ('\xaf5c1093d152de4e3336f9a95375db88e9eaf622'), + ('\xaf5ce75c33ad0d4e0d960a82c38e44d8cb1923a6'), + ('\xaf5e8961131ac8c927820b350e8689de25fdc742'), + ('\xaf66c07dc2386c2084151f5d747a6413ed67dcf8'), + ('\xaf6bb7052e85a96a12382f312e014a5796cc04f9'), + ('\xaf7548e1aadd1f2f36dfdc186644fb3e9b6648c5'), + ('\xaf76c004110b81bae7878ecee3e86bf384713fe9'), + ('\xaf78d5f6577261fb98098ab74fade324e6d502b0'), + ('\xaf7f0b1bf3995cb659b552144a6c3d83b42c270b'), + ('\xaf8118d23b5bf4b85fa34d79b8fe2d1465c70177'), + ('\xaf829a34b0ceaecab3d56a45b7bf0712ab464c1e'), + ('\xaf84a721822650fee186958ee8a7daace6965813'), + ('\xaf89f781706371713b4b7e1231ee49078a46303f'), + ('\xaf8a93690da650da7b0126aafae0bc3a78df80b0'), + ('\xaf8b9db767934ae545590fdf33efc148f27ddcfa'), + ('\xaf90b6d1da00a769fa23d15e9afd3e5de00a40d6'), + ('\xaf90f90e7dc16706653c4abbd54d72df726736be'), + ('\xaf91b51913fb2c5e683f65dd68bdf1f6374f8fca'), + ('\xaf958373e997a39637daa5fed297f0c51051ab5a'), + ('\xaf9b8a66aa9337ab5f45a86db202e6ca54c110ff'), + ('\xaf9c9b8577be00d60e76c07b74bbf13c21d43af7'), + ('\xafa015a2a28a1bcf24c535544e272a34f7154e70'), + ('\xafa4d565e72ec775be3bef7cf64df8ce7b832bab'), + ('\xafa52249c6299c0e24e62755a1b9a25ca96f823e'), + ('\xafa646ffe6f3ef29bea2fb6ed9e6006f11f39c2d'), + ('\xafaf17e3b03fe6e2ec6f954f6e473a65c8b75f37'), + ('\xafaf92d795d1ff8b0c2dcb2fa297d853be461b8c'), + ('\xafb51bc86962b0541ce6edc2691981f45a119cd8'), + ('\xafb6b817dee10270def08cabe152c687589969be'), + ('\xafb7f4fe8d51fe5116300578070f486cfebb3fc7'), + ('\xafb9ce0beddec5f576a3445feeb5834f0fa3f5a2'), + ('\xafba327e229f808053a7d0c87c9ddf3e6b4a66f4'), + ('\xafc9291e1fb319615fd8af81275d7ac2fe632a06'), + ('\xafca4c3a546b93e432baea17a355302e2997c023'), + ('\xafcbcb555726b3110e17b86a603a86160dd0d199'), + ('\xafcf08a75de2d39818fe1d89942812388e369af5'), + ('\xafdcdf68f213a548a22d307b13b29b08f6680cea'), + ('\xafdf6e19dd741807b917470bcc6e017fbb59e90f'), + ('\xafec7372a1f2e68d2df7655834d51a5d34870d48'), + ('\xaff7c9fa627166ba13bda3a9d4eef382949e8f6d'), + ('\xaffe2860d10b996fbf567bbcee05829f26e31d56'), + ('\xb0052c78e0a6d548529a5f8d8431218983af1da5'), + ('\xb006ca6ab80dd1686efe1f9ae02e1909ca351dd9'), + ('\xb00a171751d5e897c70e9ed3a60c6c5a32fe83ef'), + ('\xb00d3a72d06cebe4b9d75a7f253e6d9d126e8628'), + ('\xb0101e4c6f817a2b9f3b4c31ee30f34cb1a95996'), + ('\xb019c5840f0cac5429fd08eebb159c9d9a1985b3'), + ('\xb01a7f32fa1f4a1c94800945638d5d8fae4dcd11'), + ('\xb01c0c78ddad2f7782c1eea3ecc301dc93566bc3'), + ('\xb01f0e4ca1518d6c86fae6843049359c01fedc43'), + ('\xb0212074f1e4a2537d7b33b611647c89de38047d'), + ('\xb023a49f19b44b6203c1c57da621fcdf14b49866'), + ('\xb031cc289a2e35a38853d4c7c7959ab38065cc15'), + ('\xb0344673657621fdf0b66ee0e1740bba7de22735'), + ('\xb03cd40b161cfa42bc0d3c2f9a9a3ea02bbb0ab7'), + ('\xb041fc3eeae2712ca771303ae0e5302ac88d294b'), + ('\xb043f1411f271bd374e413391ca0fd3f72446394'), + ('\xb044e96593a5f7957587434a895d226b89d19313'), + ('\xb0450fe77e1e86d737b66963ba87d9dc1ef63d80'), + ('\xb045ed0986af388d5f992c692e14d04aeac6f1dc'), + ('\xb04ada0e1e26e0a7fe46dd3e16d8298de6ba3185'), + ('\xb04ef1165371aba37f5bf8b53c3847eb5e6e3aec'), + ('\xb04f50291efd47f6815856f8fda415bbdfee0828'), + ('\xb053d28103a0712f4f058c9cb45cd90914927c29'), + ('\xb06156fc483e4c118cb609af386e30e10efe0fff'), + ('\xb0617c6d64eaa2e41b4ca47f733acca848323ed4'), + ('\xb065752c2dc75baa395d2bc34027badf467734c7'), + ('\xb06a36350f63b74e0beedefcabd256b7fa135a86'), + ('\xb06a6c2974d9df352349323cff6681ed881c1a24'), + ('\xb07e279e69ebdfc880d313b238fac917ec148f0c'), + ('\xb07f7d9c530236c572bc40204bf174734cfb87c7'), + ('\xb0853d924330d5026ef1e4d878371b4c12576564'), + ('\xb08b804d47780f74a5192ec720c154a63a9a3ea7'), + ('\xb08d7612ae1d1ae4e81f2978a3fd1a7f42799c84'), + ('\xb08d958db76e8980c5f63029b955108cd6828a45'), + ('\xb092b325465e4cdcaa439e873c5d441da5b4e41a'), + ('\xb09ba9532ae910dc679abff666ae8dec2c755d86'), + ('\xb09c53c3b3d880d4c7da555e62e452a85c14af2d'), + ('\xb0a7514fc4966d2ecfd05a36592d3cb3bdc6c04f'), + ('\xb0a8a45a4aeca5369b5fced8e941995334436794'), + ('\xb0aa65dd2b763dcac7aad6fb9bec6dd094b285b5'), + ('\xb0b03783f0e82864db6188d69c3c2c44444ca8b8'), + ('\xb0b041efc0e8bfea828799669b8a91d8c99b0825'), + ('\xb0b9116a041b3ac4351768eb758ecee7b7ccb2af'), + ('\xb0c1f09b24da49b62eb0f740f5b3a0d1693b6729'), + ('\xb0c78056a38d5ffc7e7ee26f148bec4394ff98ee'), + ('\xb0ceec7198147f14784ad7f8b03481950e687236'), + ('\xb0d2ab572fd882ef69474b1ea6be042950a685a5'), + ('\xb0d439e3727e980bb74c741042c472ab1f4b697d'), + ('\xb0d9efe1c4ef33cba893081f160bb8d57fc62824'), + ('\xb0e52af661494aa539016d6531435e3af4522eb9'), + ('\xb0ea7429e28b85ad763d06ddebfec2d0c621b13f'), + ('\xb0ed620f54090dd51eed0f6786d7907d0da1dfb8'), + ('\xb0f18d45d4abfbc114fcd6056327c58748640c77'), + ('\xb0f73743e1b890f9ece2600bc01d837aa0847763'), + ('\xb0fe680309a59ab025420bac5c21b8bb6528264d'), + ('\xb10891243b0b2ce86483de6ffbe1f3224218b3b3'), + ('\xb11b23f3dfb861f0d2e906fb2f108a586e459f1b'), + ('\xb127a6f6f6222f17c21e08b6899d5f041592804d'), + ('\xb12abf4c32ff81f2e66cd43e1d4b63e3702e4272'), + ('\xb12fe86ec04f2c8859dafd1b2872a4c7f823bf9a'), + ('\xb134fd6bbb66631b8384078f06d54c78a4d740da'), + ('\xb1367335402a99c0fbc15be77a9adabbeebcb499'), + ('\xb1383e8046b1dc24dc2e810b30fbc777c9c18acb'), + ('\xb1416950395b3908688b5b6fa8747f40d7c89d37'), + ('\xb142eb6a5efadc2629a2433f7ce0d52e6438cae5'), + ('\xb1431a125ea11ddc9e0bd54dd3945e31d2f9e91e'), + ('\xb14744555b4fdc4b01124c07e20f99005f4ee5eb'), + ('\xb150d5578aec3950ebc0100f8147d6ee07705116'), + ('\xb1546b4dd19dd3b0955994edd808bb7aa12d9a53'), + ('\xb1572cde05154f37549e535e3c7b3469708c6130'), + ('\xb1589240660c98b4a6bf1ee6af00b109852c707d'), + ('\xb160a2518bbeb3e8fec7edd8d37eb06b92b912cd'), + ('\xb165d8703582fbf813d17abb7ba917097104c847'), + ('\xb1690f55eae5364787477ac8ac0e04c920c71169'), + ('\xb169171b3eb19e7b571fd5f09341f32f025bcf43'), + ('\xb16c9e2d05081c1ba7c5730c6e94e106e8286445'), + ('\xb16d43b3d7295a389d3c0dd230c90cb3b852ace4'), + ('\xb16f98a7d58823e8447051072adfc6e5ba058d74'), + ('\xb178e69973c2939506478c92a2ab99597e902a79'), + ('\xb182c05393e4a53b5e14a2004f852bf384867756'), + ('\xb187effb09516d6d2bda58063ea846f3133cfabf'), + ('\xb18d1cf34bb46813dc3fefc660e19ae152c1c4f8'), + ('\xb192cf4c7f35dc7ccdf93b26501238e2920c3e1d'), + ('\xb19e8506039d0ab9cad31cb49388e01bab3b100b'), + ('\xb1a22ac9cc7e1ac2f742de398c1893150a0ed283'), + ('\xb1a243545fa38f355b457f1729650c059765ed8e'), + ('\xb1a840b7e3b8982da1d7f6c6ab71fdfd5f3609ef'), + ('\xb1b07c4b8c5543a960f60a3df21794c943bb3049'), + ('\xb1b4800f3c3386ad4c4dbe7750c2790cab200678'), + ('\xb1b78a6256646189da658bcce718b95b12b7131e'), + ('\xb1ba6688b6e8a5aa07e124a2f383773e29f14ee3'), + ('\xb1bd3c14606b56c0eebf287ca67ca7622d0c9479'), + ('\xb1bdb2d364b119869b599b05d92e627a548b00de'), + ('\xb1c0d47e5e61088d54ae35a6f559a2076ca43b4d'), + ('\xb1c847f31c05c53c7bff1b52759ee5dad4003b24'), + ('\xb1ca65cfd0cfc425d9fcab67568b9aa61bf082a4'), + ('\xb1d436815b8f2043ddd3235bfba9ed620dde05c6'), + ('\xb1d8c5c50b7a5e70b2087734d13ea2a45679d701'), + ('\xb1dbcf57f780ed81df3e6e1c989acde958033dba'), + ('\xb1e11fe8f5bf7276617e3bfaaabcfe402b765358'), + ('\xb1e1f3280d6fc9ecee826d5f271f42bcf6e8f560'), + ('\xb1e9d6f2c06791c3e520834d47f881759f0e6533'), + ('\xb1eb301d504bfaf9a93d53295e1c8bd2d36104a6'), + ('\xb1f150b69fefe5ff860507ec2066a9040e762e05'), + ('\xb1f4281cf0235f61de6799f99363676b08c93d34'), + ('\xb1f9b7103b7a67bd4c0b42c33375cd7aef2ed86c'), + ('\xb1fc5f5b94edaa3339b21050396ffcf76b8396c0'), + ('\xb1fc8dba6cd24acde9d00e722a99acbbcec1a1b5'), + ('\xb1ff09258cf3845bc0a831642190c1d36c54c4ef'), + ('\xb2015351b259b7775f5babc4ebbc20a7eb0d6ed5'), + ('\xb2053efee6eb59d84a6c2cb0c8eae884114c16b5'), + ('\xb205dac15389541f77ea3cef56ca428600e6419c'), + ('\xb209fec75a5756ef0bc3bac9df30a02aedfedd99'), + ('\xb20ad26000644749c206bd9da4fae70de39c5ea6'), + ('\xb20fa082e2a3fed9d69d9d88bdcab8d5235f44a6'), + ('\xb21690c3bf9624d62a6dfb3e87e3dfe3575b9739'), + ('\xb216a1058b4c0015fd059b4ac957720967d1944e'), + ('\xb217cdc055f5a3a5717fd4dd6f8848c8ba269c4c'), + ('\xb21c8a7ed7e43e24f7b1f3c598a8580aaa5ef4c8'), + ('\xb21d62f9b6da448bdb0a5f9ed270c5219847ea45'), + ('\xb21fd8ce6d1c4048b63ac37110ed13eb8d0fe807'), + ('\xb22032c21af62c2ad207935521fe40738ebb13b9'), + ('\xb22df8d38bf63c9abf5198327cd5575254c17a4a'), + ('\xb23768d407624ade3d78f92d9c43c777ada20a4f'), + ('\xb23788b441068fbfc4fc2313028ff1764b739173'), + ('\xb239fdc6099c3ccd2d8be635f6e3b3b6e5ea995c'), + ('\xb23d37f0c2b24b881c9888f71f1f81b9f0588fe1'), + ('\xb23d4a3ed7277ee6f16119a995ef13997d020ccf'), + ('\xb23fea1a7aed8dfcaf347fe4e0e899877b8bce49'), + ('\xb245195e24f4bf1dc223a1f0e789e7b5f57d8738'), + ('\xb24d89cc112c7eddbf958b066178d3609f5cbf72'), + ('\xb25a1b22b668c3e38866b8b4ae4cbb3c34831294'), + ('\xb25cd0ab13246df5857059cf8b7955569830337e'), + ('\xb25ec6a7bba9bc2cb1e1b95e535cc326dd49eca6'), + ('\xb263c48339d8f5c047af0031c41e31163671e128'), + ('\xb265ca1605a2fa2ea03347d3327a51a02f0714cf'), + ('\xb26c052f9b36437a6a06c57d5896fd7c9339e0ce'), + ('\xb26da3d6c3ed0ed1a059b09ec1bfc4b2a1f9490f'), + ('\xb26f0c29e5fd583fe4f6240e21faf88891c19f6f'), + ('\xb2730fb28c375951535e4c10cca5b4fe053b017a'), + ('\xb27556d761f9ff2eef2ee886cbf01b3b268a2caf'), + ('\xb276d4cd94fe94e0cb586bb3a558bb48d679ca92'), + ('\xb2781088594528fc85f5425fb6813a5fca8add15'), + ('\xb279cf233056840f9542972c6da29f986042faa6'), + ('\xb2872fdd770bdd8d9c39af1170c392b1f31dfc2e'), + ('\xb28b04f643122b019e912540f228c8ed20be9eeb'), + ('\xb28c3d3112e6e392304f76777788fd9093ecbce4'), + ('\xb28cfd98af517823106ec899c69bc78b7e4a8335'), + ('\xb294332ebbbf18d2f10a429c1e621ef87c1b318a'), + ('\xb2a507c946c78a80ae4e805dd0c840e265488c6c'), + ('\xb2a6abca2987e4868306aeb8573855844aac2e7b'), + ('\xb2a9d1a88bc62db7ac3828600315b8f1de41ce75'), + ('\xb2ab633db9463763d3b02341110c01222e77935d'), + ('\xb2b391722eecb62fd388ba9e3816dde65b5be43a'), + ('\xb2b3e601c324abb2441d77fbdff566004f02d19a'), + ('\xb2b52822df4ee583849f07e7451eccb9e8f6b7fa'), + ('\xb2bdab10e706c205bdd3de7210752b7c2cf1876c'), + ('\xb2bf791e6330b204f2adc35113afcd10ccfe59bd'), + ('\xb2c3b84f48fe67ded2149c8059908b57825c4813'), + ('\xb2c77b9845749059a60a151a9f18a7d1634b8318'), + ('\xb2c7edc382044e6361bf388cd1a6900ec1866306'), + ('\xb2ca618afcba3e21f0c7ba68403452b8dd34f9fa'), + ('\xb2ce589074b25e095c2392aec3ba3bc6cebecea6'), + ('\xb2cff928e42a31496c603d02b4605978eba66e59'), + ('\xb2dc3311d68468b3e8463d3eb28d3a5b12b3cecb'), + ('\xb2dc5cae34c494141ace7e70ecad30414dd20632'), + ('\xb2dffa62396a72d8dbc0967bef717ece26c12c08'), + ('\xb2e5b9a2578eeb7b41ac3f27921006a2a9d75925'), + ('\xb2e78abe5cbe1b3d2e75d5d7b44790aa9a237124'), + ('\xb2f3ae7dcb9fee27eebd5c83f5eca6188c2c61c4'), + ('\xb2f40bdf785820ceab93e76a580b5bf30bfb681b'), + ('\xb2f7fff9ee43894bb70fe6864c37c2800281f476'), + ('\xb2f888cd3bd09106c97e51005263d84ddfa828dc'), + ('\xb2fbf8cb7afbf6193d786d40c9cfea473bf4d684'), + ('\xb302486e68bcb47992e15e13b910df5e77143805'), + ('\xb30d0fb4a655c9f1a2a4f7f32ab2cbafb1b587da'), + ('\xb310254cbd3407a12a7d9791061acb176d69a977'), + ('\xb3172c27e4f900637b3f80baa6445300e010c2c3'), + ('\xb31ceb7cff17fb38538b4f5c723cd1d20f9a1d19'), + ('\xb31cf9d776746e015e4ccd41c38625193af1af8b'), + ('\xb320ccf3650602714d44a5e94a9319ff6edf71a5'), + ('\xb3288bdcfe9e518eef834fde520ace94a580a34f'), + ('\xb33b130f9b29ca530c80a9bd6ce9dce055399b70'), + ('\xb33b28545023570f9e260756c9a407a51c703808'), + ('\xb33b54759b88293f627c7dab65c35465d781bc88'), + ('\xb33bf728608c4d246e8ca4f090b1925369147024'), + ('\xb33fb5fee7c67ae007287a66666a7b70468bd6d9'), + ('\xb33fe065a76e10d2859da4eec074566a4e54be6e'), + ('\xb345ccbec180d8d3240c7b6f966a47d787e3007c'), + ('\xb362e9bc23c57f133e38924dfb0ac17dcec04908'), + ('\xb367fa2610792b7cddd9dc666e7c39cd48f99ad9'), + ('\xb36d1893d61027b73237068c50e3083b2aa82ecd'), + ('\xb36d5aedecc468d84515653527834542047f8ede'), + ('\xb371a3514526159e4add2977ddc3ecf15b09b143'), + ('\xb3752e28c8941e7027205070beab723d956c4a63'), + ('\xb375e6a9adbaf52b7bc4d430cdd08af33eb98c67'), + ('\xb3806f2b00d25b089110fa0883401bef38ab8517'), + ('\xb386e679a974112436c68377f1c8dfc43bdaccd7'), + ('\xb38e3680b0d83d1310f434a83361acb4ec7a20e0'), + ('\xb38fbb9730d51f61596376dff826a895500e0d31'), + ('\xb3947dda4d3836d6d8ef80f6fe2bdf0df7ba677c'), + ('\xb399e03e89cca4c6e0b819bdc447a5d0a9a36378'), + ('\xb39be32d358c1128dc147fc660115ffe4562d277'), + ('\xb39ec49786fcdc184f48ad92e4deb58ff4538275'), + ('\xb3ad641a10507ba0b43bb30fa4ccafd5326de1b3'), + ('\xb3ae500389ad24b1920ea07f6b05ab2f36460738'), + ('\xb3bac0697e5b3497c001b01fff67e8f36054a245'), + ('\xb3bcc1860077f794cbc32d2d7ae6d7fcbc45ebe4'), + ('\xb3bd052a609aa6aa02a543a8baeb0e6761393ae9'), + ('\xb3bdd0a0820299e98eeeb1b28d997f880f89429f'), + ('\xb3be02a5534e8d3b85caf3abe16f593186f9920d'), + ('\xb3bf260c8f3744b0d0908244cb3d9a76b14a3fd9'), + ('\xb3c12c9a78f1bc06950593b32bcdb5747d04dab9'), + ('\xb3c355dcbc7c067865590c61c511425f742c3448'), + ('\xb3c7ae190f1c0db2364af27098875fb43e56c316'), + ('\xb3d1c32740d18570bff3c4e11af42d9c19336b93'), + ('\xb3d2cdb8de681e20447636eac3c387857e6e2d63'), + ('\xb3deb66cb1c87cb69030dd6dd7992d053484bcc2'), + ('\xb3e0aa695542a507e7dcb4b7e1c82c5f80d8bb22'), + ('\xb3ed66c14275604768bf342d7ab368bfc43d5a3e'), + ('\xb3f10a18f4ac0ce80c0228e5d7de1d154e8a5980'), + ('\xb3f46818c0943e012deec7ce81a1db073eca7550'), + ('\xb3fcc3f55fde3d3d3ac16bb79962e28681505624'), + ('\xb3fd36d14739aca63b951c2e7b4a20c5d977e4af'), + ('\xb4175946f65509caf8212ef014ac1bbaecf7e42b'), + ('\xb41781ea210faa99714864d7c89989c680b873c9'), + ('\xb41a8608811ee9ea070965bd972f7baa3764c8a2'), + ('\xb41cbb740e940ba513c109c977dd0f079bf19e49'), + ('\xb41dbbff7605d6ad7ea653b9fb098ab9348d5d2c'), + ('\xb41ee15a4b22060df90d53724db7f162f99f2583'), + ('\xb425144190bfe14c7deece4021df71e0a1be55fb'), + ('\xb425f9ceea70c1e76d38d66f712caeccc1a00626'), + ('\xb42b3cd5fd8f1b64a52bf00f2796ca6493a2adcb'), + ('\xb42e50b234956f7bb0f93817d50b3516b5b744cf'), + ('\xb43889e19915e6c8c81e693ec88a97d1e22365d3'), + ('\xb43bd9bedd8982723d18a4ffb2729e6ef2dd6198'), + ('\xb447c3c184011b40cbc2e83a80303b83a13cdb84'), + ('\xb4499c073bd26e9a2dd6225be3dd0a89aaf9c1db'), + ('\xb44ab26ad22868d2619702581ed171510ff368e5'), + ('\xb44e98cfa72f30bcff34abc7db6f94fe7000d701'), + ('\xb44f378326d5e7f8798bab1646c6b79b52c44554'), + ('\xb450a1bf3c6e044c4b60cfda7628ca90f580fce5'), + ('\xb4550cf88a48524221ae3ce591de416bc2deb7f9'), + ('\xb458961f1f5f6ac2c02ff547e4c08b44aee65595'), + ('\xb45931ac8247ff3ad1f74e6db32a4c7224d2fe06'), + ('\xb459f626bf0afbfe6b399f5f65f0a3e36f41bab7'), + ('\xb45c4961905ab1797749835cf1362fb68f36fe3c'), + ('\xb460e8c8a30dca9ff31c188556f30c532cee7199'), + ('\xb465f524b022567b38f2fc224c0970f77bdb9ac2'), + ('\xb46dfe0e3245ccdd4055db3ed3874be66d5770c9'), + ('\xb46ed0e23f58853d2f88d3cdbeb40548c2b46ef5'), + ('\xb46f4eea1482fe05058a33ab61a49ef5091f235e'), + ('\xb46fb9d228c8df6d93c38c5334a73daea6a80040'), + ('\xb47076d1b9dbfa9660e17735191f25e8f626655e'), + ('\xb4734f1b2ced93e166e7a8abb16867e6fd4d5409'), + ('\xb4759353491b7a03c05df9de7b4dafa93c4dc065'), + ('\xb4775bfd9d24fe60f399d9c30669f968fbf8aa5c'), + ('\xb47a4a292c42ea71398dd6932155906e2bdd4395'), + ('\xb47c1370e1265f32b2532ba71758c11b10164290'), + ('\xb47cde0e4a814512d672ef63813155cae96b4838'), + ('\xb48686bd02fbbaf1734d74a73c14eeef87369374'), + ('\xb48bb0e1bb2fec581813efc217e6c0e3f0f594d4'), + ('\xb48ffeeb527ca5637d2282fc961b4ba0b14aced2'), + ('\xb4940904f8858368432e218759fd4a4e7aa61b8c'), + ('\xb4971c68c7348e7d81ffd41990dbb4abba4c5a95'), + ('\xb49f79e7e83077468976d4dd706fd9698015c78e'), + ('\xb49fba441c8d3b3a93b815affaceb8309a00e3a8'), + ('\xb4a19a673d3e6a7961c6b11ed006552ac58f6c73'), + ('\xb4a2030b0ab5ca5dc7bbd0a6d8228078742f560e'), + ('\xb4abd57f68ca57ebce5d8bc8900242ce4cfaa56d'), + ('\xb4b27dbae7c0ccccfdb8297f70839150123fc11e'), + ('\xb4c12bd04c7770efd6839356a7125022d0bf91da'), + ('\xb4c677f2dc4dd44cbe75c191889c942962535522'), + ('\xb4c96df563b07e4e7d0e1ea6948e83d44490f457'), + ('\xb4ce1dbc68c7cfa7570b4cf1122ee9c0031fdd03'), + ('\xb4d04e1f2a4ebbd6e79c48916a445879d4a06b28'), + ('\xb4d2185f85fb6e7c047099ad6d8ff8a199892d4b'), + ('\xb4d4de60045f3e47b9005447f6690a588f069580'), + ('\xb4d715dece7658f9ed674e65c2ba4c5c086cf07c'), + ('\xb4dbb5cff59403687576a8da28d7516203020880'), + ('\xb4dc0b6a82965b8297e8c6ea9ec3cb79a7f769f1'), + ('\xb4e7842ae76fba3b78b1c91d58998e8c7775bfab'), + ('\xb4ebf381126cf3da03268a8ec78283dca3929b0a'), + ('\xb4f0fc06f55c22fda96440f3f168667ff2891b69'), + ('\xb4f1a91fc0e893feda0b294f9885b15ac7807237'), + ('\xb4f1ed71f09b8d42eff051ad1e0b6c1900109ce9'), + ('\xb4f86badd03691e84c3f9816b0f1482e41cc7d1f'), + ('\xb4fa4cd3b4500ab87e375f4e94240f3faf08e955'), + ('\xb4fc945b271feb3be8bcb2aa82a0e1376660298a'), + ('\xb4fdc91627a92003159c2ae319af15e409531a61'), + ('\xb4fdd8df30c02c89e4ab6497af47e48aa2c2e2f8'), + ('\xb501edb5950534aaf901978faa3f89dd5688ff73'), + ('\xb5055a1c6296ef7d494c299a6c2d873d4ab67872'), + ('\xb50886fe8f498707fff13fe12a8d7b407ffe73fe'), + ('\xb519775ddb322abdb304611a1e93322c87f0cef4'), + ('\xb51b0f39aa373af0b61c36b9421756f842934f31'), + ('\xb51c0a8fa140dce66817eddfe69d00e67efdbf65'), + ('\xb51c56de2c4ebf8fba9a75c973c49b9d39a8b5b8'), + ('\xb51cc40f81f2c8bebba3c3214854fe82f6493021'), + ('\xb5276f1ab78e1320cafedd0bfd326f5bbb748702'), + ('\xb52a91e00a7a5c82680b80360e28f82a49de67b3'), + ('\xb52c2a7cabff347109b697622697e74a595168c6'), + ('\xb52c594a06c5ce87cc9d6fea948fba5ae5012eb7'), + ('\xb53c174901bd1a92118de04ccbb03a788f9c5989'), + ('\xb53de583573b737887d2fa486b87a8cde34ca452'), + ('\xb53ecf43bccedc8a28fe3a5fd625a1d9ac9d04a5'), + ('\xb53fd0a3d86e4cc63632004db49175bde7ab0a45'), + ('\xb54937c84309afb63ae12097fdec37631b15b7e5'), + ('\xb54baf41bcf6ac93e5c3b32657e2086914a43b1f'), + ('\xb54c1aa5f007403bf24dc8342eec3c3119c60087'), + ('\xb54c739981dba657dbd3048d3b44c26f926859fc'), + ('\xb54f1a2c8ace29e5ff515e00285ed6165dbb783a'), + ('\xb5502382f2a59ad5657877f1cbeddd777c41c71b'), + ('\xb553549ecec8e0df8be5751e05187f1a99b7f298'), + ('\xb553dc8a320e4c6ff2c6ff60f03a08ee5c4626fe'), + ('\xb556cc2f1a81afe621cb04efb60fb993b90bed40'), + ('\xb557b76e5809e31e849ac9a249753c9d69fbc745'), + ('\xb558dac9091c80013c17a21d5417c75c67ff4a29'), + ('\xb55c682c4dde0e5bf5154a3a8116caf7be90f27c'), + ('\xb563c96a2d312a698f8e1947799062f3421cc191'), + ('\xb5658996b53e0b6f692cfe45ef7acdc477a914f9'), + ('\xb57193563f63d6ada408cd5483cb6d596d3e4563'), + ('\xb579c88b9b73b2a0ae9780821e7c95e056efd5f1'), + ('\xb57c226aa4e4fa1c58447193202f059cbaac431d'), + ('\xb57c3468a6a34e6e90d0a46e1367ac248220689a'), + ('\xb582af6953abffd57a21dac84dcb6886d8b226a7'), + ('\xb58547730b0fe3c0c09e85102921039676075295'), + ('\xb590deeebc5cab992248ac7d3cba80002cc989c0'), + ('\xb594b20d0a1eb8062c074c057e00edd28fffa9fd'), + ('\xb5995504a494142b74e02e7b7cb4b513a2e911ae'), + ('\xb59e8387b7a43f7bcd6dca278c70697c32255feb'), + ('\xb5b09b12a7589a8cb376cfa1889cd79e7d68506e'), + ('\xb5bd916bddeae6da8c1ae6ac153119edbfd8a1fc'), + ('\xb5bfd43baa42a1da76e3e344e7558aa61d103de1'), + ('\xb5c765b8b5b557d75906617b8eafdb4f1a6f88ea'), + ('\xb5d1923fdbbee8ad2d38fff82a628bbd9931aaf6'), + ('\xb5d4bda40bacc632fe432e019cf83010cb8ecf27'), + ('\xb5d9cbe3cec10dcff4c4a2cb0ced74766523420b'), + ('\xb5dc2595f7a911d6aa255aa9cf9cd061a7db693b'), + ('\xb5de3de2eb234509b6d6c3b5b8c35bba00f119e8'), + ('\xb5dead07b99116adc17e79ceec0bbee0ce520c5d'), + ('\xb5e426da26331ab3aa9a9c5b5cb6d6cd9a6feb91'), + ('\xb5e874ffa1dbeaafc7bd630de4316a341d7203be'), + ('\xb5ec4957b2a23836c195d9e8d016f7605d677590'), + ('\xb5f119cdbc349043345d539a96e47904e8534065'), + ('\xb5f1692d6fcb21aeb29857528722ac9675fdb86f'), + ('\xb5f308b5b192fe4b38bb67e5d63b34259913f237'), + ('\xb5faed5b0643cc75d964a8bba012e54188732cfb'), + ('\xb5fc45f34760641d3732fd16d8fbe5ee1ccb4005'), + ('\xb5fcb487d915fd25b28cf6740f7cc9c7893f9e7d'), + ('\xb5fdc61f8ff178918838c32f58c194cb7aa8a110'), + ('\xb600db777d8681fa5903d6324b8fb636811bda27'), + ('\xb60c5c279a208349d5832d0d6e7b08d6a0e32862'), + ('\xb61d66397467bfd76ab44030d78b219b0c3aaca2'), + ('\xb624448752e21dff0cf3d279fa2169c0baf59537'), + ('\xb62b6ad0a1ef8ccd254b02057120d91ec88e64ed'), + ('\xb62bd784c05f71afc8cae97b829c96985ded4a20'), + ('\xb63650bed38894412721cc64ea5c5ff3a56c6d13'), + ('\xb63a9375431cb48de22e50861c4c042bce7044b2'), + ('\xb63afd702f27b2a40ea61c1b4f8c4e94573b87a4'), + ('\xb63f4ea350fcfc9d301e0b2f574e3b6b6ab987eb'), + ('\xb648f8d3f859364731c0b03782dde8e1a09caecf'), + ('\xb64c9adea7c8486f8bcbc883d11a62c9754ead50'), + ('\xb653bccf4aa007bb2e7772201b356b7d6aa41ccf'), + ('\xb65839669f33c314ce64d68d5683ed4901eb3c0e'), + ('\xb6625c5d0681f58245b11851fb3ddcfb47aecb49'), + ('\xb6649dbd7687a53de92ae02b5204bb5aec5f115b'), + ('\xb6660297030ec790bef5890cc8a9168f89b93b3e'), + ('\xb66bb2835fa3d1409c141cea1eb1d6d948c0f352'), + ('\xb66e8d26c07c6541771ffe78bc52635c751bc7ef'), + ('\xb67a30f1b51c56edbe806dec1282f57431596e8c'), + ('\xb680af9b5e6bba17d99f07f20d97f3f1f4f748ac'), + ('\xb6813bc453f43a7ddcbd73ce7d1da60722282ac8'), + ('\xb6864f083af44ef5f98e9d29f1b61cc2acfea86f'), + ('\xb68d051cd98cb139c3bc7348c14ebefcda433052'), + ('\xb69ef3ccf782365f98b04ec0433b1744e45c2d69'), + ('\xb6abd53e38bc12df6a75527beef10280d6d7a51a'), + ('\xb6b4917fa4186b28d903f9535b5ab6ba0d1fb73d'), + ('\xb6b6fff45e05c119003ebc0162344f7b7eb821b6'), + ('\xb6b9a227f5b6faf407057afc6f5bd24701291da9'), + ('\xb6bdfe263c039db5bb662f662b0bdb7170fa27ee'), + ('\xb6c0d4c7dad6c4ab3fb8deb2d92dc01db34919db'), + ('\xb6c49056b666e94c465dc287e2f2a32b9fcb79e3'), + ('\xb6c74818398fe8a605c20dd7fcf623cc80f9e65d'), + ('\xb6ca38e3888ab5eafa668d44410cd63db65aa8d4'), + ('\xb6cbb2feadaa43b3a690a20501b9e1c313ef9f05'), + ('\xb6cbef076ff41454401e5ea12cc29336f003e970'), + ('\xb6ccae56867b3ca61493d8163de075e38134ba5c'), + ('\xb6cd052a27e5aea7ca13d593a7080679a5919a1c'), + ('\xb6d2599b4d7de6416ae5fee9273e627754a00a07'), + ('\xb6d8260ca2e587b0b956fe6e3da73f3b401f45dc'), + ('\xb6da9fcd4ef2f5a8b59e8e36eb5af58cd4e0dc47'), + ('\xb6dc1fc162caec2fdd2715bc923a6f61a2255257'), + ('\xb6e1941d1062ec2bee2f9728a872d7e223fe7f73'), + ('\xb6f42d3b44c49cd60319ebe48e7917aba3996e77'), + ('\xb7026840d5093961c07c3bb4c563261b3598b099'), + ('\xb70a70720f5401e335a28b3a72974d7cb4a91c08'), + ('\xb71ae01b6f257f5f74fd088cfee41c6d69b8c851'), + ('\xb7245b214592363e2b0aaf4522d0a4fca63654ce'), + ('\xb72880e97d056dcc497de938b85bda4757e0b188'), + ('\xb72978c315ad743ff0e8b2e376a2babf33079df0'), + ('\xb72febfc7ed6a4c31972d154f24d4a1c024290d7'), + ('\xb730ba005887076a72e269f7cfd62e08b4e5cc70'), + ('\xb7324e1fa642ce174c4acbdfb5341a711e481e22'), + ('\xb73a25d5857e475d8a298fa026bbda3761f571eb'), + ('\xb73d2088cb8bf9d7a29df4e093de491aa471a742'), + ('\xb742d96cb17e2398d741b2be9c64c781a1af587f'), + ('\xb74386b2d441e1438f761d097a6d7e123288ae95'), + ('\xb7473f7d39a20c91a3299168f29d9d2340ad29cb'), + ('\xb749d69054faf808f95bfbc5c9fc3cd677f4d061'), + ('\xb751a0200e0443bb9e14880ba40e2cb8a900980b'), + ('\xb7560f57750d21407225ddfa3eefc60255bf7e99'), + ('\xb76287651d17555accd5e5a0860b1f807d573af1'), + ('\xb768466852c5936d06e3fd63d803eaaf6324b2e5'), + ('\xb7738160b77b8f07e21712ab68d0b272a29fe772'), + ('\xb776fce937a6896d6217b49b7e33065d6b8db533'), + ('\xb77809e81220797b1d5fd7f6bcc178955c55bbd3'), + ('\xb782cc682cc0290010ffe954a823eefb5d20d887'), + ('\xb788396bfa634309384efa9112e6e38c781b2886'), + ('\xb78925e58b5f521d0492b68b2722044299e24608'), + ('\xb79a987374de57ebdcb4442b5a820ecc481be8f6'), + ('\xb7a572d9e001c9f946e7e43cf7db1542022bde30'), + ('\xb7a6dd6520ca8b5906f75e48a80054a7d20eac14'), + ('\xb7a7f15be4cc2f83f680ae4f80972c82e87a7f82'), + ('\xb7a8fbf432a5d2c84d1b076907535685d4836a82'), + ('\xb7afa62d781edfd62f671340757ccdfc11a9a451'), + ('\xb7b16b5988ddfa19bd0ee9fb6871377ce25fb2df'), + ('\xb7b24fdd8e41eefbfa00bb3cc60ab7b3fc0774b4'), + ('\xb7b79d5556d679d633bb5263a044ee7951f2c13c'), + ('\xb7be72fc89bb63c5e5b91596452f8fc3666e4785'), + ('\xb7c762573092bccad050c38d6fb4df00b79d6f85'), + ('\xb7cb9cba733c43ec8f70946fc381ba0ec80249e8'), + ('\xb7d2a881b05f15ad36a0b5b063097459106adca5'), + ('\xb7d7bd6d6054052011db543d688566f37b8f9dd2'), + ('\xb7df24813f77a20f261c9ca119f403d47a2932a1'), + ('\xb7e9ee0bde17bb2791545793809311a96ae2a017'), + ('\xb7fb3cd28a4dfc4fb7d0c6549e28722726ba7d42'), + ('\xb7ffeb31313dd30dd8dce115ba5e5f2b8a8fa9f9'), + ('\xb80489596cd7f129dcbaccbdf0af4d2f8d29494d'), + ('\xb806b2cc134366d966d153447718d4a1f361dca3'), + ('\xb80813eb57681ef8563d0296b7864b2612f298ab'), + ('\xb80a12e4b2a254eed5c7f4830662ae1ba99d221e'), + ('\xb80a47219e063ad19ac3f33f96eee4491082a32b'), + ('\xb80adb4f6d73f59e840f15305839a35640d02065'), + ('\xb80d2473905a00885195fda42b734bad54efda33'), + ('\xb80de449d7f802ba5fca5eb0f4920420e6fbbfc1'), + ('\xb81af0b01ac7c9722ecc7e4398976c40ebd0e74f'), + ('\xb82203be009307961d061d6ef12b8c99a3b944b4'), + ('\xb822a1161b55bf29ddfa3398d17ab3d5ee107eb6'), + ('\xb837e1b67300dffaeef7a502360a30de6768881d'), + ('\xb8382cbf47deccbf2ea092650eeee77e5ea4cf07'), + ('\xb838e9c3a611acaf14b2f56a119c324ac08f43b4'), + ('\xb8446bcbfd8f376efdf1adce1e91f24d30651e0a'), + ('\xb845eb4db9220338831a630ee122ccf8de8eac6a'), + ('\xb8472e79d4a7f54900ad67850ce2e79e21e84b95'), + ('\xb852b1546ba4ae4d78c884fc00db256bd86a127c'), + ('\xb85548e2cafb47372a499fcb4096d810fae5bb9f'), + ('\xb855830d1019966c2d20dbf76165d4ef9425e8cc'), + ('\xb85a0c90e110cd30fe66858f47afcb949b87d6bc'), + ('\xb8651ff335b069236ca4731f188750456aae1e30'), + ('\xb86aadec8b4b7b2276cdbe4679a3291af35507ea'), + ('\xb87376bb5d2d50d165a7275d790e10e4ed16b0ee'), + ('\xb8788a2cdae1458973df2f628ffbf062ecf9554c'), + ('\xb87bc1d086347f5259b74cdd920799c84e64ac28'), + ('\xb87ff51951890db9389a765e055065666efeda7c'), + ('\xb88149fc94bc6f07d3156f0bac2b86a531763013'), + ('\xb88528e002597ab84232ecb5da36f8fd18e71ce7'), + ('\xb88d1b61fcc88402894f47aa0cbd682563c67327'), + ('\xb89184dd8cf07982aa56f0d53acd84e6144691fb'), + ('\xb89563f4628c8ca235b4778d141717e2044169d5'), + ('\xb89c779936e3b455aa7b661f4f9146ed7afe7c18'), + ('\xb8a03ea5fa5cda9b0a13873921fa2b02b7718aad'), + ('\xb8a944e552fee5475f8f6fa6c2903e5b133fc540'), + ('\xb8b295e0a61abe0239303dc83e486d042b4a8bc6'), + ('\xb8b522fe46a4d775da485364f897e255c519de78'), + ('\xb8b532f09fa09b056145baa229f29c2d56fc9c62'), + ('\xb8bbecfb1558060cc37c21aa0396a22f0bf36db0'), + ('\xb8d0c1318b66e81d8067f983b2dc1449faaf7e43'), + ('\xb8d15394cb0a866541b5969c32524f948903db9b'), + ('\xb8d38f3030431127200cf66033b4045769604c52'), + ('\xb8d5e2b9e9a05887c9d8348b299f0be45a96862f'), + ('\xb8d7d95eb2193461c855a0cad092d8d5614cbcbc'), + ('\xb8e0c5d1f1925510755c2e701b95c66391136a35'), + ('\xb8e2149628d3d9cceae6de0f8f01a3706fc3d0f9'), + ('\xb8e6f62a8b183eede68f768c477ea531c61ff191'), + ('\xb8e99093711c5037bd75b68e22a20733419f1133'), + ('\xb8f3b903ef1c8739a20008138360855e3ad8deca'), + ('\xb8f52963716da01c0ddfaa564aecc1bd2cce9d00'), + ('\xb8fa5953cff38a2b8a90f7ce58ad6f7c9578821c'), + ('\xb8fcd5e26111a43fdd453a91da79d24d58909046'), + ('\xb908014c1efe1f13a579858d886141be7fe35f7a'), + ('\xb90a3f8078ba3cd57b319aa89ee320330cb06070'), + ('\xb90e557ce1ca715d28b757f80d3376a2f791ee7e'), + ('\xb91267e1e72fbddd709c9547f2d7987fc0329216'), + ('\xb9128501c0cbe44a5cb6f878b06518281e9aea75'), + ('\xb916c0d3a993a6450a9c8f98ad828738ea00fecb'), + ('\xb919c3774d2db0d079e11288bac756a1c20cfb3d'), + ('\xb91a69528cf82f3974a2cb9066c2d94272e31d80'), + ('\xb91bc91e514e9812d745b9b6034da679466d9908'), + ('\xb9204f71b848daaa9e718de0e6499115229e5b42'), + ('\xb92887daeccde7a2d03205b0a79e4a4bac2186c8'), + ('\xb929f6ff98895266a8c656506d446d81b2577c61'), + ('\xb92f1c90dc068f6fc7296d855fa4a2bd6ea43341'), + ('\xb936320532870e4e8b74fae1dfa7c7cbf576d970'), + ('\xb93be344c6f8a16abc681be7e36c5e46cd694949'), + ('\xb93ea5ca5ce3744f78b8d1a81911fc0fcce3def0'), + ('\xb9425f447f4c21f486337b9554bc45a0484b75d7'), + ('\xb94861f8a9f28702e07bba3ac2f812d9fdac16fb'), + ('\xb95202ce2a83134f62ab1dec3fb38e204f93152d'), + ('\xb9523b0be8e7d8050cf4da2a8b0a85622efffe5e'), + ('\xb95ec040dfc4ed9589c5ade3e52acad33d2744a4'), + ('\xb96af7e94e66e0718f6048e9ee1336bf34a7b45b'), + ('\xb96c4bc887ef59adbae12335630c57606e649177'), + ('\xb96d4f7bc0164bc9a897c303df1aa1a90e36cac4'), + ('\xb971a3fc8fc121968cc7ce0452bb79e924529823'), + ('\xb97409fa232d309827ff180ff49d5343b492aa27'), + ('\xb9756c438397c1ff8e1f12ea1eed1a75384179b5'), + ('\xb98866adf0e6bacc7a68f46e75616f6f44cbc093'), + ('\xb9887aba3b164327f0fb3d92089c518f3996fe74'), + ('\xb9892d1e1739119f46126d3ffb1fadc5e2d8c65d'), + ('\xb98bfeea5c613d11df9036f5ed9f1abdfcb6cecd'), + ('\xb98cca55ab88053dc77924360ec8837f0bcc1e8d'), + ('\xb98de10e81c7742ae4e6d5402c8f4fe85c8023b6'), + ('\xb98ed9020153e722e07a372c634fceeaf98d6ee3'), + ('\xb999f92859878127facd52f45f871a495fdb599c'), + ('\xb99c7d086ea85f119bb65256f3f9129875d83c2f'), + ('\xb99d1a895f6dc54c7c82d2b1bfaa6e9c60905bee'), + ('\xb99e68d4604b8fc305dc48424f737661f3bdce53'), + ('\xb9a120058548d1cb24c219ae0da94ece20ac806b'), + ('\xb9a251afaaccf7c334e0c3b13d7cf444f19bb707'), + ('\xb9a5d827135328f3bbee9b9294c6c2b86ff2739b'), + ('\xb9a6efea04817fede246f7eefe2265a309afea13'), + ('\xb9a7b80f47b9e45c33ca1c7e26d51d485be1c08b'), + ('\xb9b34a759812e7529632166ca1fe25fe63ab4342'), + ('\xb9b9f6f439c1887368ab51738258041abbcfbf30'), + ('\xb9bd8c520f2073488e1ead7bf4578a5fd53ab30e'), + ('\xb9c2c649f2d6acb128b92136379711934b023036'), + ('\xb9c34701beefa4ef713b383765da1f1a529c80b3'), + ('\xb9c34da35ea9e5261982e100886842a81a52ba83'), + ('\xb9c78b769d44ba54761e76414582cefa0d185b33'), + ('\xb9c81b31370884a67176f2d3bb51d24df62b28e5'), + ('\xb9c9e9fa65d409f1a55c8ccf4ff9dd7361de9f17'), + ('\xb9cf5183983b89a5fbce80749cee8ea36b0d3c68'), + ('\xb9cf9ab53c0b7172001647aa85428f7b8e8c4144'), + ('\xb9cfeaf5d8653ea85c3f37b774b761e08b6084a9'), + ('\xb9d1084d4a97339648e9e230ee8a35cdd2e38aea'), + ('\xb9d2df15d0dc5b60820d7fa4debfe747b7d13ddf'), + ('\xb9d6e6c7bf905dec071dcb1eadb800021baed40c'), + ('\xb9dc45df1b1015956953b99198e01fdec14e56be'), + ('\xb9e67465eb0fc86697f43fb22061ff4b030a615b'), + ('\xb9e74cb47a3ac89d909f4e9fce680557cf761ff6'), + ('\xb9ef934b2cc40e5036c36cf67b07b01d48088770'), + ('\xb9f50d4ba4754c97d99926bd9d0d5aa92300dc72'), + ('\xb9f8436018fa499ede24c7bf64bd52cdd5bdf5c1'), + ('\xba01aa5ee83270a232af2589fda842849867db07'), + ('\xba04dff77dbe10762f71ab41fb099a5d1d80a2de'), + ('\xba0508f21be1249519ed458fa38ea043e8e21eea'), + ('\xba06226e88bf71b6229951989cadd91d97a35a1c'), + ('\xba0a1ef95899b0c70304cb687b20a15508e64f79'), + ('\xba12e0460a2c2a6497e0a630e590bfa46653cc4c'), + ('\xba167a113219bfa350e96d926af7614b3a5bce61'), + ('\xba187a6ac737d5ce5bad46209c3569e60549a1d4'), + ('\xba1cb22d73015901753f6c2f87a3840766ef7d26'), + ('\xba1e274c7297db2e0e72e668af46519b4258127e'), + ('\xba20a6678290ac57763fc9333cacd885aa7cfa2a'), + ('\xba25779b663973f630845fdff7b4c42d024ed344'), + ('\xba28dd23edcd5d581b9bf0f8145cc90f21df1b5a'), + ('\xba2e8787c2d5e58cc30e8678f6dd84a24a85140a'), + ('\xba30c5db2944507b910baf46b71bd9ed6fd2af42'), + ('\xba370789c5170178ef553f901aafe04426ed3c42'), + ('\xba3d902d993e64c9526db034a7ea4c78665e65e5'), + ('\xba517eb6ab478f1bfaa9a5221f95a17efbfc6624'), + ('\xba574572391ae4bc6c1cad7a0b8d00227bdc687f'), + ('\xba57677c3e3e0525a30ef770a0e79d2eaa97bfc7'), + ('\xba5ae9c0fc1f329411682e1e25aa30c0ac73b6ad'), + ('\xba5d937aacc3e0c3559f46cd5c22edbc2eeef515'), + ('\xba60cf4c15ebc32184fc584d8c04c4eb99d9eb9f'), + ('\xba622a43ae8468641790b03899ec2ec6e0e3c887'), + ('\xba6c8cea05d6515e76367fbf766e6dc6785c8292'), + ('\xba72dafda7fbc4ddf5dbd46a19ddb68e9f0264cf'), + ('\xba7ba5f8d412ef6cc826197a43a426c600f2baf8'), + ('\xba7d34a585d50d76923bdc362ac7b4234c21ad20'), + ('\xba7e101e8fd26d8c627e494b2f8e1223c5c87c42'), + ('\xba8164edad3a525cdb41d84a505a4e0c3a04f02d'), + ('\xba885e7fb263a60314554d6b8e1a7654c7784570'), + ('\xba911e14fe57c1bcb282d50fc99b5fcba3035c04'), + ('\xba98e70f5ca5ded81c61c5db72efea4113d33c9f'), + ('\xba9ea45df2cfbf4091fea9adfcc2d9edb329b8fb'), + ('\xba9fa80ab7e23f90200c512fac06e33f11d12bb6'), + ('\xbaa6611a651dcd972641f4b75b206b36d2048349'), + ('\xbaa718a3363966f107d4c742911ccb5867b6ff46'), + ('\xbaa8f3ea6e53f073ff8e26ce73bc8e4344c74f08'), + ('\xbab402e8195e4c500a8fbe9ced62dc3492a993b8'), + ('\xbab762eb3d2b7354dfb31c753a2534126016346a'), + ('\xbabc94d84db82444441114c33eb9d9cec5fdcbcb'), + ('\xbac2dd904606586627c0197719e66025aa94470b'), + ('\xbacc8c1b49e7242e616550dc106143c0db4239c1'), + ('\xbad44e00804c6e50fc078547ced46d39519d3b6f'), + ('\xbad4a444b6ae109cd9d7db7db7185449c24132c4'), + ('\xbadadcec7aa1214d6ffd40be207c307e24841ef8'), + ('\xbade374515b6ec33fcb87775bc277a2fd438483b'), + ('\xbae1e344cebc17acc9e550c48bea6309b3812d1b'), + ('\xbae35814998099b631520fd2427a54db2542dd0c'), + ('\xbae5f5e3dbe871963f21426b5a3b78c23b60adb7'), + ('\xbaf5b74a2d8b9376aaf4db3cd07df58d8e3a360a'), + ('\xbaff11141c3a944c3221a6e5e0597a98ac4b37ca'), + ('\xbb09a9c978210f63b78d60e52bcb91a2e146c540'), + ('\xbb15618f90ab6b0a0a38058782aa940bb3c71341'), + ('\xbb1af1de52a68f3a591c9c0fc7d337fbadc3b3ca'), + ('\xbb260803db16d163cb5f83cfe1828bbdee6db045'), + ('\xbb2819ff010419fc9ca0381efb6674afccc65c5a'), + ('\xbb2ed48c44603e577691bec353ddc24ee04b548c'), + ('\xbb31cd2607718ad2c6a1237aa110d33e4c0e1d53'), + ('\xbb326be0bd75ac2962ba61773eb129fcec9c74df'), + ('\xbb35862ae2a759f72bfe88a29ac7b66435e89d8f'), + ('\xbb3c3cbd41d7aefb41fd877e57fb88c42200a25f'), + ('\xbb3f5f379c2fb2df0e3cc89e080eec9008117b7a'), + ('\xbb445f258d44579cd63e592827280511c7c7940e'), + ('\xbb44e554f815075c257d6433108fb99baafb50ba'), + ('\xbb56301f3eead8a59114108639ef839e559d7440'), + ('\xbb57df6979999d3ddd77065a3868c7affd7f53e7'), + ('\xbb58e6aecf2a43a9ed50f785c7b00de9ba555964'), + ('\xbb5c6c837a961175fed4e12ef26919b09ce2bc29'), + ('\xbb676e2ab8b41d043a3aba694b2fdaee1ed900cc'), + ('\xbb732e904c65edf99d94a4d073bce7a3272aba48'), + ('\xbb79c9e11a9eab561c9ad578ec89790a7f8d9727'), + ('\xbb7e2d102c4a9804a3f602c53047642787ab4087'), + ('\xbb8fb7a0eb6746479be60b2f18c8db4ea8c38226'), + ('\xbb8fd186d3b0d61f65a424690ae31bec25010074'), + ('\xbb90727f495be97fe309f2b37fc0a416599bcadd'), + ('\xbb97157976bc9605b57b73f87396962e36bdfd17'), + ('\xbb9c3d4f0f83258df46e0575d5d5e3c25fefd763'), + ('\xbb9d16d171e5d6d082b689a995d2944859054488'), + ('\xbb9fc427dcba731d7cf2af5e63624ff86a70391e'), + ('\xbbaf78dde5486737a01da116a59dd8fd065e8990'), + ('\xbbb1e5ea68c23acf901c938cbaee3f9a0c6a56db'), + ('\xbbb38727f0200f1f9d9a4b55835ee0382eb4342f'), + ('\xbbb6058d90a22e090d0dff75a23b81bc498737ef'), + ('\xbbbe005c88fb9f42b4bdf8c2644337af0641d2cc'), + ('\xbbc18e58b18d237052cc35b4c755c6f20947b058'), + ('\xbbc2d8480eef62fe48d74209f18d9c25aa1bd944'), + ('\xbbc85de016b7c2d5bef0c3280c21193ed9faedfe'), + ('\xbbcb15353bccb4eea8e9329f45290d733d507cbe'), + ('\xbbd0f8d604a65ccd40413c5e881227e4b5524ccc'), + ('\xbbda3b1d0405be7505d74add6d306b0df8a50baf'), + ('\xbbdd5238d1104442c2a0731cc7164499f69dc589'), + ('\xbbe0689e25cc705f76e84b38095a94a80998ec9d'), + ('\xbbe12b61f58900c8984efa946ddaa4f15380b22a'), + ('\xbbe640f3c7ffb1dcd1a2404faa78f6cb830056ab'), + ('\xbbeb562ca46cee3596e18962ff47a61ea72d30a1'), + ('\xbbf3f7082dd7541b3cc749dace9873ea016fb86b'), + ('\xbbf4fc96971a53a055d365881bae1fd308d98653'), + ('\xbbf75dc44c102c55deae34df2c414015a7ae7eed'), + ('\xbc08fe2e41e331fcf35fe07812c5f68f637f79ae'), + ('\xbc09a384e3e08881f6ddbc44ea5b444a28141de0'), + ('\xbc0ca6f224fa230c48277d2fab1e4168cb86b23a'), + ('\xbc11570db269c209e202fae7548698966c57fb82'), + ('\xbc1ac613c78b4e2c04df34c40f599cd472341acc'), + ('\xbc1eec932341f4b2f26e1b1311650bb9dc87eef1'), + ('\xbc256c5b44bc0b44ef2b1c68c62c967830e597e3'), + ('\xbc2955f58380e6722f222f31752369a589d64a07'), + ('\xbc2b488e9fbcc2602e28f132a05fa21edd044146'), + ('\xbc3109feccc702860584a07a376795506db80d97'), + ('\xbc346329823728caff3c9bd080543359929e2fe6'), + ('\xbc34d7b38a64368c56fd9f683335b6adb8362667'), + ('\xbc38396e2eeb4ee57b1b263f903566f768fee335'), + ('\xbc385d704111c398ce508143fbe212af8e7936b4'), + ('\xbc49ddb54045801a9e6763d50cae5cd3e6ec7759'), + ('\xbc4d66832008a4a05e4f8703d6ca5a8eee3cc359'), + ('\xbc54fd505de0bcf2b6a50f3e6a94a2e589e5bc2e'), + ('\xbc550461e18f9b82db23405cd03bba2d0a639419'), + ('\xbc5a7e46fb321f8429027387f8bde2c743c07ae8'), + ('\xbc5b397fe06173d73cf9ce2d1c76ecfc22a141db'), + ('\xbc6317d4dcca5c3a60c92fdbafbba2ce3dfe65a7'), + ('\xbc637ae51618c7f2fd9d8229d65065e56d143780'), + ('\xbc64254f2ffd8debea9c173a6522696f738624f8'), + ('\xbc66ad26b815885f88b1933f42c7f2d854c57b61'), + ('\xbc6ba1ff1642e4926fb30c941beac5bdb0789a91'), + ('\xbc70da91225a68d2b2bb5822fe138f5f5694c077'), + ('\xbc7df223b922a215a19fee1a202dcf7e84a21f63'), + ('\xbc9b2d830ffb7a8b1dc8085ffcbf9811f144920d'), + ('\xbc9f5943d8ccef2c40f051d564f34aef58bc315c'), + ('\xbca407d593bd1eb62f578c15e1735b51276e68ae'), + ('\xbca49ca8858128db753f4ad356c6254853b94737'), + ('\xbca6ef133aa506492965a633cb48be1c366df470'), + ('\xbca6fa787571482572380d290fd1c1b6e1714936'), + ('\xbcaea81907c73624c9e467b908ab61897568a57a'), + ('\xbcb4b1de41a206c5a23f61e9af003454ba78e96d'), + ('\xbcb694fcae84c41cbc65b02b93165846178c0453'), + ('\xbcb6a3cb8af683d437bdae2130d59e2f5deccd68'), + ('\xbcb6b849c9d992b6db5f58ab3c2bc2f7374e609a'), + ('\xbcbb7cc1ff7e60571cc10634e995ee28c3c1e322'), + ('\xbcc2cb3616e04cefd2c0542ca93ac9f4c8fc5ca6'), + ('\xbcc8f2d7f472078859fa7fb5c8def568a0cb059b'), + ('\xbcc950ea4e9acccf760c47fff1c9611bb5b74c32'), + ('\xbcc9ccd1463632c69f6ec8b01182a769a2cbb8f9'), + ('\xbccc6347b33e0002d16016cfe53dc2f1cdd52517'), + ('\xbccdb8e5ab55d0165fc2b31c2356851835cb9a8b'), + ('\xbcd2a78bf76702caffb874c35b1a5b6aefe01ea9'), + ('\xbcd47ff993d0a09aa57322acc8d99d9719b5ab92'), + ('\xbcd4e854e4d5c61a291a8a2b0ddf7259f161689a'), + ('\xbcd8c9f2c65b7ad4d834d32f5d0cd735d338f15a'), + ('\xbcdafc04e277d2820b40eb2a293ce2eceb7a64ee'), + ('\xbceb0999aa21ca50f7dbbe74c49322ab00c1e5fb'), + ('\xbcf0764aef98d2b11c3fa6425de5361fa17112e0'), + ('\xbcf329c7357db13711817b20248319de59ac92b3'), + ('\xbcf7ed5520b159346668086d8ad2a19ff8a2cc80'), + ('\xbcf9193e40ffe66819008e6f594133ffbac7b0bc'), + ('\xbcfaca8d2528fbe542fe6eb3aadbcf9e5e299ae9'), + ('\xbd0058156357296ac8fb513d50245e98d8d946b8'), + ('\xbd0c7233df081b5573a1ffd5878d131fab921b1c'), + ('\xbd16f161a1846ffe36ee1c24f4596d41d3fc468a'), + ('\xbd1ac98980f7c692d4b816d7cb8b26486de518b1'), + ('\xbd1afa5e91260f8f45ded3ee660a7aca8e37e19d'), + ('\xbd1f4774f19780f461e44d7fb1ee5c54f9d28713'), + ('\xbd21b0787ee09e7a605a9e7621c1c55c593fe8b1'), + ('\xbd2e12dbfcdff1e6ab1df40a1e03951c7a515f84'), + ('\xbd31f325ad7db55fbeefe46746d5b5d0da41006d'), + ('\xbd36fa027d085dd467002eccbc124fd51771dcdc'), + ('\xbd483275d3c6d464a6d7c205d572ce5309db78ad'), + ('\xbd4e89036d4d9431ee6f1fac19e13017d41d864a'), + ('\xbd50aa45708d99095835ec8832ef6d7f0e7842d4'), + ('\xbd55cb72537d819fce3139ff1172565cd5417253'), + ('\xbd574e82ad5ecd59a0d66a0e671e9cfb62aee106'), + ('\xbd592afad25c0b248faeb9b4944ad4eb55530cc7'), + ('\xbd5b6b16639d408832a2b4b3021c8239b74420eb'), + ('\xbd5bdfef46fca36ac3cefa2d70a9b5b0869a2d34'), + ('\xbd6402d697cc85bd2bc6399678da05aaa45bcf71'), + ('\xbd6b5e2f858c11efd8fc1ff4ea4f8cee02c94fcf'), + ('\xbd70e0312c42f038b4320d9ba6febe29181c9a9a'), + ('\xbd7334b18883e53931acc4b79f05a98bf3677df0'), + ('\xbd79c7edc58dd3c793004f5252b46cf0a999904d'), + ('\xbd7c91c921b330af7b2d44ebfb718bfe74752b13'), + ('\xbd7d62cd04c8fc3eb2ec77909590e1a4223e4c7f'), + ('\xbd7e3407516bc03c107b3290e9556310bbea6e6a'), + ('\xbd86308616c35bde0406574b01d641df0ec81ce9'), + ('\xbd8634c25506c38bb832746db2628e4d55f251c5'), + ('\xbd869d762403a21be0f60e6b12fb2811c3d0dfac'), + ('\xbd9083b33a19bb34f5c0b9c4a7f698cff88edde0'), + ('\xbd90fb70f20e47ad932819bd2ebc5136fde3776c'), + ('\xbd91d4cce7cc41ffa6871c645c903abc75c628b2'), + ('\xbd934c8f212655d565fe4a3a6222e8e928a1bd2d'), + ('\xbd9aec7db49f3bc65ac3426b383463cc115291c5'), + ('\xbd9b079317b201481ff183b283729ed06fb65420'), + ('\xbd9ed50fd178a7e2903b2908384b1348ab943977'), + ('\xbda341f632e08eb2340526dd3e6147492526c889'), + ('\xbda36b2332d6a2985ac1f3f702af9373f62f4202'), + ('\xbda6a1ea5b3fd646a8cd40cb78d5a66955891511'), + ('\xbdb43f238254d8395cde9a042485d260e85386c2'), + ('\xbdb6f440421fb0350553d56fb8298583e99f6bbe'), + ('\xbdc2d16c738bfd3dddf7027364efa3cbf476e699'), + ('\xbdc9ee852a5e0c9376157cb5e56d051ed6cc0e66'), + ('\xbdcc668d520919e666dc17b4c55c2fe0582c429f'), + ('\xbdcc8ab14b6aef4a2eedfb081b4890b2a3692524'), + ('\xbdcfb27a4fd5374021f41a0af62ea7a9e8a5e3b5'), + ('\xbde457bbe6c2eaf88cd7a372578cc6ee64e0461e'), + ('\xbde9e7b53181a74dbbe7089e74fa18c7eadc2385'), + ('\xbdefb58982886f1577da103380dc270dcec0c573'), + ('\xbdf21213bb519ccb96b5a65b8939c1e8a19d3bf4'), + ('\xbdf3ef1195e21a512a943503515f365990a10bdd'), + ('\xbdf54d8d96d6e6389347aa614f664bcf497aba98'), + ('\xbdf5cee3c664150f5dfae622a899c45b8a2ad56f'), + ('\xbdfaee916efbe80cb8ac59f3dfd4189916e5e71a'), + ('\xbdffb151f136e2ebbb05c5883f2ffb8c6e7cfac6'), + ('\xbe043f14a414d759ffdfb9a9db68cea17d0f7e22'), + ('\xbe09d5036f857d9ede87557d4e96308441f8b034'), + ('\xbe0cb933aa85ce9f52dae9f4ab7bb452d32f44d7'), + ('\xbe12e3342ca45bfb654c4e3e9db592cca93ef273'), + ('\xbe178aa9e5f23011184e2098b96acb3f322e5115'), + ('\xbe1d9377e7ac3b2ae90b880765a301abab297186'), + ('\xbe1f8f498d82fd5f9c3232d99a6578b10a773a15'), + ('\xbe219b70aa2c3deeb6627cea7f4e6afb8a08fa3a'), + ('\xbe2433713cd1506abc88fb1a41315c55bf34e42f'), + ('\xbe311c2f25e3529b0ebd6903d502a01e4b49937b'), + ('\xbe39d352fc6931eac55a82835f64392dda31fee1'), + ('\xbe4c81b4465f86bedd0620fa37d9e60b3d888333'), + ('\xbe57aaaa44f3419f2c8c7f7136763d9a62401bb8'), + ('\xbe5e6e5f925482457ed621fba37b373ef340a3f2'), + ('\xbe6afceb63ef4ab4004cabe923e0f64069b0f759'), + ('\xbe711fa9e4ff8ad3e727faed6e8cceb01ca71cfe'), + ('\xbe71bacac0ec72456aba45774b43df76e297374b'), + ('\xbe776b717f9c583d56e7c279bf233364d7e42ddb'), + ('\xbe7aaa236bf928ee1e94128aa7e3a16b35742a80'), + ('\xbe7c3f18e13026391ab473756f420f0fbdd60313'), + ('\xbe7c45433d0da4b79b964708ec8bb055c2d374cc'), + ('\xbe80f2d9b7bd1ad01dde07591a6c933374b4ea9a'), + ('\xbe836fad12efc89f04d338abd97be4339c8dbb0e'), + ('\xbe858e0ad5b2ff5aabacacdbc5f13fccb04bc155'), + ('\xbe886099d88d5dc93495492f71549b0e1e0b6668'), + ('\xbe900b1a4b644cde14f349b0eb7f6e79031c8086'), + ('\xbe98de6adbc55f74bd1373c3d5802c083606a19f'), + ('\xbe99873699529e83992d40af01d743037a4837bc'), + ('\xbe9b56eb7f450d25fb68817fdc8d8b84e1182a6c'), + ('\xbe9f4494ecdbb82905d8d9f0e86d271a6ff59634'), + ('\xbe9fb48845f4ca606eaf147bce87df6e18fe6d15'), + ('\xbea30ad85046505b09665c0c3155ae444a74d5a8'), + ('\xbea5c2e53789ebedfcd8408e50336536ccd66eca'), + ('\xbea9370c38deb2489ffd3bc23595e148b103f774'), + ('\xbeac7e46a6469b7e754ac394098177720f03208d'), + ('\xbeb3d412795fea6f86f98d74b17cf659c02743f5'), + ('\xbeb8063e61a133cde545ecba70c1c13d6feda1e0'), + ('\xbeb8d3c08a98e17cdb0d77ea7b1946e3b1a59084'), + ('\xbeb9f910a0f57a249f8d5df3e4e41b3fbca17e0d'), + ('\xbec0fe9f1b724a2c68a9c51ccffaaa6a12f0f126'), + ('\xbec12651a3dc690845b98e33365395af3d3e09f8'), + ('\xbec2f2d7ebefd3111e91b260530459123329bee0'), + ('\xbec8a01d8489a8ce41199e05f2503a7ad6f51a86'), + ('\xbed006c5fce53d37efab514e6ef80c0ed62c4125'), + ('\xbed12c377aca7af6b1677e1c527c4c16d3e141d7'), + ('\xbed37a7ef18bf55acdd85884678379f2c4f56d63'), + ('\xbed59f55fb2630c6040a45d7eda169158639d868'), + ('\xbed654abb263ef8ed92889c15b3109b6ecf04741'), + ('\xbedf7e413108cffeeb248e452b3ae059e2d453e0'), + ('\xbee0b8333d2b4a7e78204ac1075fe4afb5f98c3c'), + ('\xbee6915df098d5b3f50c1b4a19ba5ddced4ac5b1'), + ('\xbeeea9fc776256a6b7ba02d9057162a53b238ca5'), + ('\xbeef989d348114530d6d3947a4be02dbf9ff4224'), + ('\xbef0c046727de53f05e4d1c17e9fcaec769dc801'), + ('\xbefbed26a82e9f6a4a629cd231e91c0bad63712f'), + ('\xbefd803e6672d314d8d8563bf28240ef90910211'), + ('\xbf03ce1db976c83069a8121c13dba7e5ab4dcee8'), + ('\xbf0e036c22c9b60ebe3512098afd51a5dd1fee37'), + ('\xbf10403ee2063f5a9561f8ea4cf199221c4f25b6'), + ('\xbf19819122f5bc6254cae6fd4d5b0bc914134a47'), + ('\xbf1d9c3ff6b2aea7d73c00d5357700aafe073566'), + ('\xbf22eaecd2a8d51c17c54b8ad36c4d6b6591bcb5'), + ('\xbf32242cd47faec705f8b94aeb0620cffa2030d5'), + ('\xbf33f519ce1e14f6570cdf45d8e25c1f597203de'), + ('\xbf4bc71ea3d636606b0905abb7f9070aa1f24f14'), + ('\xbf4e8793ef4f30274fd6fa6eadfe11f0199e1390'), + ('\xbf55efbe5146b53d237099f79c3bfbd2a0cd753b'), + ('\xbf6696c9ca0369d2fe2b3fa69488666f02ed0757'), + ('\xbf6d9f1ce85bc101fb4cf348362872bedcc23775'), + ('\xbf6ea5f002e8e48f553d42752cc5aff311cc0503'), + ('\xbf732a85d1ee59e735d1429a9de2f9e6d19d0a4e'), + ('\xbf73f5d89a9b0bf071210d96e4f40f930ef19fed'), + ('\xbf751566ea02a0ea7fc4556104538ad80a225758'), + ('\xbf75fabe64f0adb80e7b75082a8f1871bf8ec66c'), + ('\xbf765ad598b97babcf6a5a8a692678074beeb5ea'), + ('\xbf773d59e1ad7ba75555f463d444b319e245ba0f'), + ('\xbf7f3cd0ace6367c1075f23fdb5e802435e02a32'), + ('\xbf8c97a737f82523e6b49a0f77d630a29607c73c'), + ('\xbf91ce950d8770a1ce6f49ac88609718b89000f0'), + ('\xbf924fc3f4d994ebea99291cecfe9e6c97cf16de'), + ('\xbf94140ef0307f6a41c62d2314f8733fc1cab189'), + ('\xbf9ac5b503c87b2b5ed8380113c64f304b324241'), + ('\xbfaa15ad93d1689ed821fa2202a70d00b35d553c'), + ('\xbfac418d0d4b38ae45b71bd44cd17695df8a3f04'), + ('\xbfb4d1e80dfb591386f17b9d9fd0d3138f1aeae8'), + ('\xbfb52a2bbcb635cdbc882153fee64f0e9ea2d0b0'), + ('\xbfb7dabafe4bd602c402919f7f4440058aeefc9f'), + ('\xbfb8b09f9f05b9f1202d4687da1cad3b7df6e191'), + ('\xbfc6a29eac60415d0841271df7db94b8297f215d'), + ('\xbfca1f9cf17da15f3bd5500613925207ce68edd3'), + ('\xbfcce9491dd601343dd4fff39bafd0d31c72bc60'), + ('\xbfd501b4597fe511d24c3de1d355f1705288398b'), + ('\xbfda5a13f7c29c3a712515778cb5ad2c449e76e8'), + ('\xbfda8c9ccbd6c5b1ace736ebe381b50380390312'), + ('\xbfdcb3a4a163a0faeb37c93f11fe4df60bdeeb63'), + ('\xbff8d0ed887916972e20c6b0575bb0891579d738'), + ('\xbffa2071e86df97b7eeea5145c2522973169dde1'), + ('\xbfff73a9c7d06cd2e555a5701b4a79ca3bb8f26f'), + ('\xc003ffe541e7f3df6b520c01e816ef5162461ea3'), + ('\xc00c64bcfbe69cf9e5e63f657f298dc26ae91f3b'), + ('\xc011f8dee99a2fa3023b9ed6a7c251e43697d3f9'), + ('\xc014101aa69fb747d9ba8652f59f263e62e89131'), + ('\xc01724185c9293ea4a51b6d92151c7b46db111e7'), + ('\xc01d4f6a09515c6aa5a90ce86c99cee02764f94f'), + ('\xc0224ff0caec3b873c07d2edb7049b7e5645cb06'), + ('\xc02809fb0a1dc22d4b6768cadca7b64f533fcd4b'), + ('\xc029aa7a9c802593f78d4bda1afcf92b1d1c7b21'), + ('\xc02eccc4743648d1bf77e33cd27b66db064dde5e'), + ('\xc030e34d6b6e05edc97e97cb2b8ddb46bde2b0f5'), + ('\xc037c20444b7a355e084d5f990f513eefb5c6af2'), + ('\xc03ef8f8edf9c984395533b3a20106e807a6efe0'), + ('\xc042acbfec83383548c97104830f653001f74a5c'), + ('\xc043c1fa1cb860edfb3847def2a6abd69213a583'), + ('\xc0499841ab189807518bc4734d815c3eb4dc9ba0'), + ('\xc04fce486aa0333a6e3bb8f49662cd0f0eabb470'), + ('\xc0545fe74708597ca75391379677b951c702a25d'), + ('\xc05bff7d5601e14a79a38c71be10b89bb753580a'), + ('\xc063d4c95a038681c0dc425e1f5e8b9f8ab0775c'), + ('\xc06c63857ca2ceeff6590cff827f7482c17c3b98'), + ('\xc07372f2b94aee72125b06f224acaab255b697d2'), + ('\xc075d7dcd607648661eb1fe89b1fcec54e756fb0'), + ('\xc0781d2b64e0823b89783ba8cd4a5b578a7979c8'), + ('\xc08296b073b9488e546ebf44a5bb4a13c1f381a6'), + ('\xc08a4f628acf4b0c71cf4dbddc2cfebfeee56b01'), + ('\xc091ac5e345c177ca694374430aa3f49842ee8ea'), + ('\xc0925120d056930d7ecc685d352a68f1614783f4'), + ('\xc0977d4a43dabb04d1b1fd229f1e723b73902532'), + ('\xc098e03d50e5a9ed3ac021da001b18c5fae3e28b'), + ('\xc0a86ba07a37e2d48146dcf1d995c0c579de8c96'), + ('\xc0aff91b61f94329a0480833a717c7d8f9490eb3'), + ('\xc0b2af9131a7983bb96fcadb86d38f13f360e9c7'), + ('\xc0b9cc0e8b6699265c70d52e9bb6e0daffe322a0'), + ('\xc0c1a11723f432a77592b383b0f45b19238190be'), + ('\xc0c3f2cbe5aeb5872f0fcabb593567dac6bff5e8'), + ('\xc0c50f0a20d7fb24382c6c2de4f0cc3e2095385d'), + ('\xc0c60bce7c114bc1e72e3d149a79ce14d076278e'), + ('\xc0d1c7e19bde17f3925638a6dcb7aed212006b9e'), + ('\xc0d1e1e894c239abc93384f2415f100cc65b4113'), + ('\xc0d4a7ea6588d40cdee8e758068d3cc0295b0529'), + ('\xc0d81044e6f4ebc3d246b11d77f1cb90ea5fdd23'), + ('\xc0db7fdd2607f0345f635c092bb88d6601b1dba6'), + ('\xc0e4d67266886371f473d2d6338e782a8c2058de'), + ('\xc0e56362b58ccb89c8d48a41abc6f2e7045f4497'), + ('\xc0e72ee56436a4a36cfe2526ef1de4b75e765fa1'), + ('\xc0ef737e038d0cbd2d33dcf5d5490ea61d4da8fe'), + ('\xc0f2d775bf5cd8078b8d89acef68b1798bb7e585'), + ('\xc0f81b3959a71566129148ec09d719548cdbcf30'), + ('\xc0f94dd9304586a101e20de49796fb5e837b91ed'), + ('\xc0fb153d7f76ad8315b8c4f2bfa465cb558def9a'), + ('\xc104ff769f4f0de8f613feb1137e89cd537f9c6b'), + ('\xc105ab83ba96234a2bf0c44cdc0424a936d7b4eb'), + ('\xc1088cb65d76a258aa72f6f8f157bd36c9e59e0f'), + ('\xc10b5831fbcbc036a2807fe73169c3933886f468'), + ('\xc11442ce5c5c3d1ba5880fa6fb778af61004ff71'), + ('\xc115c2cc322317f63bcb430a97f233502ba7732c'), + ('\xc11ab9c9b1931449f1e0d38f734d8fdbe3970350'), + ('\xc11c7950400152eae22770b8d3169fdacece6033'), + ('\xc11db8bdfefeeb09a0d933be274393a159731caa'), + ('\xc1269e166291b509224232d7d07393f80b4e0b57'), + ('\xc127d9850ea002658e1a4964abe3ce58d19599c0'), + ('\xc137fd54c36733597a4f3a769b4fdcd0e544c4e4'), + ('\xc13b1036479506a4c7bb99be5590d8f0a5c500d3'), + ('\xc1421fbbda04674b42bb7cc497eb421041dbda73'), + ('\xc1423a7457e636fc525457b18292d750636f51de'), + ('\xc147c39968ab6ba2358c02a8a7a7541dc29e486a'), + ('\xc149a66cf221c2a9eecaf8cc43daac0306e9425d'), + ('\xc14d8914d6edde3d2e5bf3c817adde2977dbbe4c'), + ('\xc155493c4a7c8ac73dc0cf822542dec39af2254e'), + ('\xc15727f689333d5fa6c3cfa014c7e854b73bca1b'), + ('\xc160a8f0678b3ded8931bfc225bbb4bee25eafb2'), + ('\xc160eeb277b9ea1e9cf02db6dcc6a81a2ab9886b'), + ('\xc1617f6fb982a3237007e490b2fa934cd7ca8409'), + ('\xc166decb1dbf465af0728b44d892ddad5e082e27'), + ('\xc167061cc6ad6affdefced0e6a82f2498ed6d3d2'), + ('\xc16b09185f67e54d20d7ddc03b0b53dc7ffa01fe'), + ('\xc16befbc98035ae5d1a903762b3fe66c02dc0047'), + ('\xc173c26a0918504286f202a43e0930ce99ad71c4'), + ('\xc17439c8711afbcbfbccb347b27c2b13253125f3'), + ('\xc17622f8a434c90ab99c0af6a38cf94ace9a5e7a'), + ('\xc17a271055a4605d5aa3b2f6b04c7f8395797dc3'), + ('\xc17a952e243ef14a8d67c99a8c2e8bf3bc434a6b'), + ('\xc17c9a8eb000d8f22327ea0c2172f057d2963999'), + ('\xc1805dfd020d535adf8951539473762e3ae634b4'), + ('\xc182119c797d0640292d660fd50bc991480cdaa2'), + ('\xc18250dd95fdf2368382370ad60f2ae8e0e2c1eb'), + ('\xc1846270ec839f1d0e6a3e35b619f035e516287c'), + ('\xc18b4b8d2b8ec85a228a27f6f0858326c64a2e6e'), + ('\xc19610e98425646992bed61e34ba29ab3ae863b7'), + ('\xc197658ac6ee884efea87b430b35b7e9884fcfc6'), + ('\xc1984b54ea625ff4a98c15411cb6c8d78525f7ac'), + ('\xc1992bfc23c1cb5f6104d64cd1fc1e5fd97ac9b3'), + ('\xc19bee3dcae924d68a0ed48ed5c565665a066c18'), + ('\xc19e2bac2da82998515d1e5ecb35aec3b6ab0218'), + ('\xc19fb8315e5d783d9d49c455d63c33a699c23d02'), + ('\xc1a1ccc3cfad5be5678ae3980a7cbf0c8585ae36'), + ('\xc1a4ff72a9d1e553651f7bd78e9fe90f0acd4978'), + ('\xc1ad297915bd6f67a141319385717278aed30037'), + ('\xc1b1fa6db4212d67509345bb6aadb52c028149cf'), + ('\xc1b4aee147ee913600f60158b234edc4d1ce6180'), + ('\xc1b6e2adaa72567f3f002311ffc62378b508cb5a'), + ('\xc1c60ae014ffe274b1fa2ead6eb62e3f12362a97'), + ('\xc1d546bf1db20c3f5b32a62778e5b23f4c4e0de4'), + ('\xc1d81a19d2865dcca27e97e7f775b65a7ff38df6'), + ('\xc1dc10641413223d42e89329d6af094c0e10ef11'), + ('\xc1df5d980927e4a6f41269a764975f947787dddc'), + ('\xc1ec826a3561ddead48c6c1ec6a5d57d6e230279'), + ('\xc1ef7c1f6637d8cef5f6e7fbc0d920df43d8cfd3'), + ('\xc1f0e376df98243f1586539ea4b606682f9ccb0b'), + ('\xc1f1d25011e32d39212cd91df007c77bcedbc020'), + ('\xc1f6fe31828909b5cbc074657be28bb0e8f06f3d'), + ('\xc20babdc9f47c9337402889158b19b39d59451e2'), + ('\xc20c111c66a0cfd01afc8b00ff3e8b1f07d69ae6'), + ('\xc21459759edaa902ce225599ca5dcdab11892ced'), + ('\xc2162a43ac8bbef691ce4ccb01b0a953636e4169'), + ('\xc21899a5419c3e307d2e1475d852ad4b0385bee7'), + ('\xc2190f3fd43b0a11b7c114eb29deab69de5bd9bf'), + ('\xc21932289204346b29b4c23c495771eba9e2a16d'), + ('\xc21ba71e2ffa761b5d8a67d2ba3816998a41713d'), + ('\xc21c53da4fe2de5b45c5cae0776e1d24ce035459'), + ('\xc21f2eb268c21a83b0c99347496cb98df10c22f9'), + ('\xc221833aab256aa25c76c1a595207942cd90527b'), + ('\xc2232f84675f8b93412d36ccacc6705e4d1f5092'), + ('\xc22c8a5c35985a2e7539bb61e060744d9a054bde'), + ('\xc231b4db711f341fed8aca674f1b58a99cf644fc'), + ('\xc233e968a0dc9870a8352c45464fea9313336b3f'), + ('\xc2416f9daecd3c6355b7f660835c3d34dc263c11'), + ('\xc246bd53de48bc56781089c92eb79c1d18eed5d4'), + ('\xc24d59b68d3ff9571c5612293fe1bfd105315509'), + ('\xc24e4263e83b32499a639d69bbf16339fcbbed41'), + ('\xc2520a37c688615cffe94f015695887b6fdb69a2'), + ('\xc254581b2fb2ce20f6ac04597b553da2821ee540'), + ('\xc25d105b012e7a7c950d4b651629689757af7506'), + ('\xc26034856848093f2e9fc1105e60d05181039f5a'), + ('\xc277d16993780f1defd0a824742218cfc5166957'), + ('\xc284bae2d4bed0e23831be1a231568c3562490ed'), + ('\xc286621aba371950377333c1bc3a9364428b5bc9'), + ('\xc291752207d49b96bca057dfb9877ab9bcaca3d6'), + ('\xc2980401dd192fae8d12ddff91e7daa476de24e9'), + ('\xc29b3a57e9ee33a46bbb333e30745554fd274341'), + ('\xc29c9a880257068fdf33211b2d0d5411ba7898a5'), + ('\xc2a4114eab37a4d74c9d74e89fbbc2dd89a4d82b'), + ('\xc2a5b41e561e7f48be65e1458532d0f614daeeda'), + ('\xc2aba20a348628870c7e219da9f33cddf9db8076'), + ('\xc2aef49e72e55ea5f329ff571f760178bac61d54'), + ('\xc2b0c4799c8f76806265a2e1e6a2e37f1fe267fc'), + ('\xc2b8a17637c30578995c7eac73d73416e7df9ce8'), + ('\xc2b9bbff15ca5818e25602bcd3dc64b07f7f5b9b'), + ('\xc2b9e22f39aa36de6292595ed00a3b0d9d87fbf9'), + ('\xc2bfd3353e379da00617df0f8a12210e529f9a83'), + ('\xc2c0fc98da377294e53e3af06ee6a555d854dc07'), + ('\xc2c4c0280c2e8d13bbf129032574a9b9117878be'), + ('\xc2c793291b8bdeb4424be262e6a344333693493f'), + ('\xc2ca81242fb19b8e03c41df23581bc3a3dfc5c10'), + ('\xc2cce4a6694d9b7cc631a80f111f5090c312488f'), + ('\xc2d092354c5a349cf1ff3304b546b7b9687f41e1'), + ('\xc2dbbd59104f9de56bc7043f4ac2557c0e1f1d4b'), + ('\xc2dc0d5c9ea3d78778291ad07112389d3083a00a'), + ('\xc2e3b205b12ef95f9b335bcd91798986b069967f'), + ('\xc2eb3ddd58da212eb33855c09d3e5a38416bb3f7'), + ('\xc2f77d8f47e8d05f6d467cb49f7793d2c29ec72c'), + ('\xc2f7c8fdd5fcb441f9b7b4e690398bec2efd817a'), + ('\xc2fa0f28294d7918dea753e07b6b00821174e846'), + ('\xc2feb41936d06c0db49a3fb23de351819abf32e8'), + ('\xc3000a8d69b39f05364fbcd3994270c4579a4297'), + ('\xc30624089eab392a618d62f29de7de1433933712'), + ('\xc3070db461f9fd7ab964f02a39686aaaa741040d'), + ('\xc30fa31a1758058eb5d2e439a291211249a6b5f9'), + ('\xc312a59ba01fa929f27a24e60692d1b8dbc6a9cc'), + ('\xc317091beb5a25588bfe3887273659e7f4d8d613'), + ('\xc319c5d427cec336fc9820f2ad46e25691758707'), + ('\xc31cb4dd31a0a7532726554f8afa73c720f80462'), + ('\xc31d6434981271c718a48865dac57c25d59292e7'), + ('\xc31fb6d297ec7a7bf526a9148733d1b2ec4762ea'), + ('\xc323519c80473187ab92a53abf85d0bd47df390e'), + ('\xc32371e10e3dbf1222cf3a7aa57069f849d3721b'), + ('\xc3254a6490f2dd65360103f5dc721cb77165acca'), + ('\xc32920424a78c3bca4d99042388f91437c54612c'), + ('\xc33220656d9a7c034c86f39df9422440044de008'), + ('\xc33ffb64457a357415b5ef66597eae710297ca28'), + ('\xc34403954f76f3fc846dad9c06fccecb98a8630f'), + ('\xc346fc8eac0fc291405262ad674d1f27e7c23bef'), + ('\xc350153be99fca289dd7814c5c1eb3ab3f191ccd'), + ('\xc3576ea0bba40761735edf357b72543950de107a'), + ('\xc35a7debf85f07464085c008f7e77bc02cb23440'), + ('\xc36546b5c48cce4b4d442f73c315c371f064a687'), + ('\xc365719223e47335c2b6101ebe57e81537effc8c'), + ('\xc36ddec26157d8f16300f00b7317772a3f503700'), + ('\xc374d3f28960115509305762f126e7f560f853ce'), + ('\xc379a212f7dd21df5ecf04649fbac3ce30d293a0'), + ('\xc37c464c0fec396354c8d64861ede146be84c774'), + ('\xc38024486f5638fb7dc1426feee7200fec2b2a5b'), + ('\xc387a01d6e791f38c991de880138cb8babecff10'), + ('\xc388c476875f34a23dcfd03dce31b1078de51098'), + ('\xc38a10816fc8f654c125ca7ae5b042dc10a76b8a'), + ('\xc38b4dacfb6284a36b839e5bebd044a985ddda4c'), + ('\xc3906af9c77cdfc93ab70621884acab7d95c29ab'), + ('\xc3926302bf0cf134fbe46f844acc6d09db9b8937'), + ('\xc39963e344c8e2ed8b4d54f3ea117a0ef65d55d6'), + ('\xc39b6e9b3699ea0add9185e297dd5c895a68b27b'), + ('\xc39fffb28b45cf96145199b45541c88153079b91'), + ('\xc3a99b995a5f444acd77c9bd0c66a733896422f9'), + ('\xc3af20e2bee036486291076fd4b318d891bf3f77'), + ('\xc3b241507a345ad18b1328c83c6b46f2b685f46b'), + ('\xc3b259f89bcac9baa05b1453742e8308fc05daea'), + ('\xc3b8c65a11d40c2a801efa349636e44c115858d2'), + ('\xc3c5d906394712b5727955bb0c9f18c33d307f55'), + ('\xc3c926c8f9eaef79b78d90d0bb5d66d74e6083ed'), + ('\xc3c9b6df066a9d6930c65b5269e316a47dd28bfd'), + ('\xc3cf69fb59e4d3869f1d929ae37bd85a45846a2a'), + ('\xc3d096aec331d049b021d7955406b7025c700d04'), + ('\xc3d317f381495684f2eda7c15c3b106a1a245bd4'), + ('\xc3d3635c115444dbcd7e49eb42f01536f4221c29'), + ('\xc3e091317c9701a4437766625a001a6c35139d41'), + ('\xc3e2aab2454fc0c333a10a6d9444ec33a4325d6f'), + ('\xc3e3a144dfe41c9fdb76b263a5464e3894d47cda'), + ('\xc3e49ea50dfc97272ae11a98b5f727f7145e6a92'), + ('\xc3ecbcdf496bbc911548da5dcd1001802af5ce33'), + ('\xc3f30b096c5884d5651b7a86c47326b8d3afbf98'), + ('\xc3f53e9921881e82b67665ac2afaa081a4374f8e'), + ('\xc3f7c0bec8a31221fb8fb207acde0c8c1bb41c76'), + ('\xc3f82023b37ce3000a170da21d830396ddc8cc55'), + ('\xc3f9e94793cbf7594cbbea13d99e8b0a71ca5e9e'), + ('\xc3f9f7d6ca2eef77726164b3dd080e803326c4f7'), + ('\xc4006f48bbd16576c7ea4740c6ea2c87921a3fcc'), + ('\xc409d9d5140d3710b47132b5440171fe8a16a171'), + ('\xc40a8d171b9fc0cf4228832fcab7dad6a199a2d1'), + ('\xc415a2678b1efcbf0aa710af34b6832385d7566c'), + ('\xc416725549ca23c701646423d997110e52a33b53'), + ('\xc41ace687ae29864c036de2ed1eb960c7c85f110'), + ('\xc420c4668422be4e0e4ed3374dbcac80774d5cab'), + ('\xc426aa1a7266db36e50ec8e8cd176ccc63a75698'), + ('\xc42c80d494ebd3c1348ba74edfa0341feffbf52a'), + ('\xc42ebf5b860d61c968f1eaded68f89514af8cd50'), + ('\xc42fcd870056f3b8dd9acb2138c5a95baecb8ffe'), + ('\xc4381f917bee63077bb2c3006256e00a07fd6c51'), + ('\xc43f0685e3f1d0caef9dde6ae0df04c09588a88c'), + ('\xc44049220cf2f655846cf0c145d5dae92a6dbc99'), + ('\xc4431e802636527d6b7eaba74a13f4afcf1e3fc9'), + ('\xc445ea0bcfceaee06ed6a6565bcfc905db7f087f'), + ('\xc449a0a74841ef332c6e58594cdf38d81aafa475'), + ('\xc44f73bedf505891a0cd18b0f01888a71e28327c'), + ('\xc45021f0ed0e36035fad15195403dacbba578898'), + ('\xc452e635fc332395544826ef7f7ff0bc1389df56'), + ('\xc453410c25a6ab7ccdfb0960977ef8135196bd81'), + ('\xc4543ad7d278698e5b87af42c5f37d1ef32506c6'), + ('\xc456603e42509fdcb7183693c7a9a278dd465d0f'), + ('\xc45bb5fe5439b40a7c33ff56ebc938c9b9406880'), + ('\xc45c681fa2100b6ca64c5e2249ae5a5de8ed6cae'), + ('\xc462bd4db570d1dcbaa8bfb95628d9439180f15f'), + ('\xc465f0c92e87207efce84f4e618f1460917ac818'), + ('\xc47c20434da2b937a007c51eb1691f5a34935395'), + ('\xc47e7ab59161ea9590e7175ea4c18ba18710b632'), + ('\xc4870395f212f0d84fb58539e74234f114c345cf'), + ('\xc488f1695637d683a5a61273ad9583d6dd306fc8'), + ('\xc4898001b5e831210bac04f0365881a44fd71474'), + ('\xc48a4dec33670a783b97318312d48b3273030e3a'), + ('\xc48dcdfd717ecdd84546bb8068939403c1bd9847'), + ('\xc48f7a26a0c2368335943ac84dc568da15df4e92'), + ('\xc492f7a942a9f9d12aeaeb852a2fddffb4e14a9f'), + ('\xc49e6e31d726fe99e27dd0144b66ff3fbc52e583'), + ('\xc4a2f20024fea97968c18db70e6ea2907fcd88a5'), + ('\xc4a4fa08ce3c13dd13ce734733f504d2deb4e9b2'), + ('\xc4a69f6eb815b9e60455333e2a49f66966cced7e'), + ('\xc4b0aed2b0ed485625d9f93794cedc762d84b3bf'), + ('\xc4b61f620c261ca8b2f9ee4a138207779b27d224'), + ('\xc4b8a99e5361fc8a51ff8a0d96a01e360259b633'), + ('\xc4bf24a5459ab3a3d7ca47069270d8e06256c4a0'), + ('\xc4c194aceded18f0ad684eefcc46843ab8c4994e'), + ('\xc4c524173fd02dadd878ce6f745cca22c99788eb'), + ('\xc4c99ed1d948fb497c07cc8bbd4eab2a3beb6db7'), + ('\xc4cbdcfe9d8be1d92bbdc3f7d0722694eb0fb692'), + ('\xc4ccd85f315146a5ac5cc92088880dd3a6477b0a'), + ('\xc4d0e899921070a316946ffbdc301302f9253bad'), + ('\xc4d4fb59a5bb5b3c87454e9d60e2a3913abe6d99'), + ('\xc4f3432869ebca16df8d9d7fb2e9bdb2bca65ab9'), + ('\xc4f3c071bf669208a42342793044a444660654b6'), + ('\xc4f481dbda30359c2778aa8de612d0711349392c'), + ('\xc4f8aa4275f2e627a744ac914d6f8c188c8153d5'), + ('\xc4f9ec5af11f06d32133c8b2ea625179c1026f4f'), + ('\xc4fc6fdbb848ed613bf0e82aa278630648830d3b'), + ('\xc50067b6a2f459a88e1183f9adc06e3c72d4a04a'), + ('\xc50193e8078afe8e3ee5f2233e0201cc3d90a8a0'), + ('\xc505bdd335e4bcfa58e46ede4d11ad4154a8256f'), + ('\xc50b7e97b2334e634efc9ed03de8272b92460288'), + ('\xc50ca5b5636a8c70f79e483ff8137115123beb2b'), + ('\xc50d1cd3e76719be1bb6c8789ec4faba4e9f01ac'), + ('\xc50ef68fa5cd459688fb63eeddc7728d1f7cf18e'), + ('\xc512bf1dd63d99b44d611f4f38edeb1e93dd10ed'), + ('\xc513093cc92afd34b31290f471e6500d4d5aed08'), + ('\xc51550727865eb57756c5ab29cd779bf1572d964'), + ('\xc51dd471aecac37d77e5bb3a1fdd494a8d8c670f'), + ('\xc53159dfb1cd4f701a86e9a6132184d9e2f54da1'), + ('\xc539699a349bd7eb133b7413544f8014260ff6a1'), + ('\xc53b73f79ac7f491ae5736646bbaadd75c1fbe6c'), + ('\xc53cc6ec96156021032e33ee3ebfab01b3b1596d'), + ('\xc5436ca74928a6b40d6ecd92a9ab1aec83654ab7'), + ('\xc544ff68e81a787e7b70b379d689a58a32ae026a'), + ('\xc5497cbd84043f477a2ecdaa548099a656e0f2cf'), + ('\xc552b2d56f0777c6edd10e1511318efe65349bd9'), + ('\xc5546ee83c5d9f753483dcb819077b795811c73d'), + ('\xc555c5fcc3b5d3effcd01a2ac335dbb1d2289cac'), + ('\xc56032e3ce3bdac112a98cdba2189d9e662c649a'), + ('\xc561a0f64018fdd280e0240a51fe830dd035f2a6'), + ('\xc568c8212b5f196c4ab9b20e94329f253ffc77db'), + ('\xc56dff04ccc0190cbd0e6a614eefab869a59368a'), + ('\xc57a73e97c5b044f2b9ea0ce178a8604ea598eeb'), + ('\xc57f395bc25b8738f5f77f602afd56de6cab6e59'), + ('\xc59b9731c2ed7466745dc9091a69a5ef6905ce3c'), + ('\xc59bc6bd61a3cc522d1c00347884b6f96fe6fc22'), + ('\xc59c2f37e39e0a53defb309ffebf24e3bc88d77b'), + ('\xc59e66e3a1a786b13541527428b5d70ecef80c02'), + ('\xc5a0da35e9219a2c471c93cfb8c9c6ac21ce4e7f'), + ('\xc5a3bf7aecc5373b862c15f20530f322e11802d3'), + ('\xc5ac741c4a4cf03d51cd88da5258e5676af3e0d7'), + ('\xc5b32001936a92dbab89eaa14d1cf67e9d34b040'), + ('\xc5b8ca86067d186937f6675da6552cee1541cfbe'), + ('\xc5b935d247597cbf8049f72cb65f65d650178cd9'), + ('\xc5bbae40f439d01ec08e9e807f0c0b78239f6037'), + ('\xc5c04819e35a48d099a42f13878af8c562e8c6fa'), + ('\xc5c10b0990dd9b302be0e9b36fdafce6cccc9deb'), + ('\xc5c21a7ba3a4f58731d10577f04b49b96d6bc81d'), + ('\xc5c3148d3bdd354ad1305f55d6f92bbe713db28f'), + ('\xc5c67f218696bdb509c32271040e60091e76e336'), + ('\xc5cea1c61889f49b99b924b618efd751cfea239e'), + ('\xc5d697e7e58dce39bfab41c1a085637a6353eb96'), + ('\xc5e1c6029bbe3a3be2a1c69e312593042646d5bb'), + ('\xc5e6b9abe1a55169df828cad50b04fac7506fed8'), + ('\xc5f366ef3b4ed9766edcc28aaaf10bdfe38adbe2'), + ('\xc5f80ed1775aed3d4936dfc2a700831ef6b17125'), + ('\xc5feb69def4e24f6dce0f5a901f6940ad5fac792'), + ('\xc6096b54b4408966edb82c96aa1a0ef7b3141982'), + ('\xc60a3063e8f0a8471de166ca3c5608c26a30b117'), + ('\xc616ded931e8f650369c59d39dffcd69633ff814'), + ('\xc618a7cac99e669ceed0ee012a7c2a05810dc7b0'), + ('\xc61c8122d5c0aad1cb9647f660478f5719aa4c54'), + ('\xc6254862ade259356b1c46fe20eee35cddb16a4e'), + ('\xc62934c54824a8583cddc02b02fb66883d8188bd'), + ('\xc6313e2fd76d9c8c64fc1463634fd39613c60387'), + ('\xc6359be66933b0f019991443aa69a51c1b06003c'), + ('\xc63a9ef7f2c8824e0ae1e43fed8a52f3e063c0d7'), + ('\xc63b4799b6693685e2b4332873fba4ff16a0d15e'), + ('\xc63d8365af38f66f6b9a97af8b3b939cf14925d5'), + ('\xc64b33e3bfada04429be9b70bb36304e56494756'), + ('\xc64e7e6c036a2dfee94f60b2578e43b49fe4078c'), + ('\xc658a1894a30bac3c3f85dbc8d582756f3f846a0'), + ('\xc6619b887ae6445c38871227f71671606b8fd5d2'), + ('\xc6654491e3ea71fa1bf9be08e71f409eb127bf35'), + ('\xc665b000d91ba35959de466b617b916b11f61636'), + ('\xc669a662e708574d9a216e24c38d2938111f4d90'), + ('\xc67106c7785ae5943021f4f56bd4f1353d3bb20f'), + ('\xc6715de87ccd145558086cc37d4983310b2a1420'), + ('\xc6734bd5910e70696e631a3a8950c5978d9a9634'), + ('\xc6752c914063b17795fb7348652ea27e05593c3b'), + ('\xc675b4e294977be1a142cbeba8e36551b97f00fe'), + ('\xc6762c9e9ec12ce770ccccc9450494fdaf9fbc9a'), + ('\xc67e74c7cbfc8426839f0f534a9ad3831726f7de'), + ('\xc68c37670ae8f13ad46f2795998c5f22e61defe3'), + ('\xc690de7194a3a4cb6949fd2c714ecf996f8c4190'), + ('\xc69b18ebee341fb23966e97b0e1b63469927bbea'), + ('\xc69c9b3b2de498db73fc333472eb5a545889c44a'), + ('\xc69e40b0166edb8f86e9c3a59ccbed8e397e062a'), + ('\xc69f0e97cafa0bc1202ced584377864931c884f7'), + ('\xc6a37df53dfab7aec4116b09630b02878b2cc8e7'), + ('\xc6a980c8fdbdc34340e1acf6279e20e4b75f0789'), + ('\xc6a9b01a99c458d7ba0409f6cc34e9fc2f656431'), + ('\xc6ab035e3573dc9d3a2eb15265ca68f2c0140f18'), + ('\xc6ad3f64de7c7450fbe2a750ad098f61ae4cfbf6'), + ('\xc6b606652bc095c62590cd8fe37931456ac2aad3'), + ('\xc6b8f386fd1dfbc1a0e508bf9e95d8a2172d2cc2'), + ('\xc6bde722a3d81013eae0f9d637b49cb9a61984cb'), + ('\xc6bf429748fb1d9430b25e31fd6438e374851e80'), + ('\xc6c687f8f5051d96b5b3e39427570acf3baf0e54'), + ('\xc6caf6e86ab73b1de7bd0053d6bf14cd672bcbd8'), + ('\xc6cddf94f3c6e1732a0cf4bfb61ecfcf442b9c3e'), + ('\xc6d860c0de85b0a291bf0e6d40d07bb4cd956ab1'), + ('\xc6dacc2d5dfd4e283adfca34115139ace2490570'), + ('\xc6dbca32a4b1f12b52447b27c55102152eb4571c'), + ('\xc6dbd2a5fe00acac476dc6a0779567c87334b512'), + ('\xc6e7a5fa33900b6195b6061cca48432b3434c805'), + ('\xc6e9e4a7bb563412ed222ca9f8d25fdaab922d91'), + ('\xc6eb7d59d3ac5ed9b29160cccfe078aa29419140'), + ('\xc6facfb74ac3fc4f5dbde0610ad721ca8cfe53c0'), + ('\xc6fd8388a71532dbfcd54e8305c0a56f7c8437d2'), + ('\xc700b48de3f65d67a50e1041bd070da4d9be91f2'), + ('\xc70283cb51145f5df8c2f1fce18ce599c5c3f227'), + ('\xc70c70f655954ef0d156b3b7a054cc5d768bd511'), + ('\xc70fe2b775cfde3696741aea4411ea57da7a6eaf'), + ('\xc71398f34133db3ddc550128500c67d3e9d75887'), + ('\xc7158f73df5c5a86ee7e31e5b0c2ff1240abe894'), + ('\xc71ba02734a4c67943b1ed106c396c410add745b'), + ('\xc723a6dbfb096df7c02f3c556ac552ccc7218831'), + ('\xc726a04f78b4ed00d58d7682c11188bf63ad460b'), + ('\xc7291ea1a10028f3f05234cc8dea5ada298d7413'), + ('\xc72a1a8e2efcc96c3d8a39e48dbbaf0c75afaf6f'), + ('\xc73e1cbf9e2b086eb450ff211f342532c24c4c9d'), + ('\xc7415fc0edfe8e3d86c9239218dd80fcf53785bb'), + ('\xc741bb05f5449a166c20135e9c957b897b08ee18'), + ('\xc753d8afa9ae8f1d16bcf9af103abec7986d6bcd'), + ('\xc75418e4bfb988975cc1568dbc3f8419d933f9d4'), + ('\xc75433a0db95f23969f2908704d268f522161ee7'), + ('\xc7570f0646455f72dbdfc4021528f1f9490572c7'), + ('\xc75b670c3dc13ce8b6e63f8d819a114686413c88'), + ('\xc75f346bebb9f5782d06fd560a01487188bef2f3'), + ('\xc7623fde6acc3e7bc3d5c44553327fa34c36a852'), + ('\xc767fc6246865f1c95ce05df46709f038a97cbb4'), + ('\xc76838906c4efaffa1275b254c167c1b715d5497'), + ('\xc76abd2bc6a413c46bf7643071cfd3f4eef6621a'), + ('\xc76f863c146da770f3562cc8009be1bdb359e29b'), + ('\xc7706108eced85b461e34c53c05e4cfb855c67a7'), + ('\xc771f8f648b07f6c9ed387d755f50d4ec705caa1'), + ('\xc772e88c97e2788994a6b04dcc6d7d7b38bb743e'), + ('\xc7733035b611ea4c6da786539a07c6d175725e01'), + ('\xc775e56f4cac0ef71a4a98a16a4725b5d6f9daf1'), + ('\xc776c349c2960144b0860fd6dc21ad6d8a6fd9df'), + ('\xc77bf9d1ed84059d7cf2641af02f6ad34b37684b'), + ('\xc784fbd536a0bb76966ac8e1ae7551458b7449ea'), + ('\xc787fc1650af47fe8401d9292bffaac25320f20c'), + ('\xc790ca16471b94b425110ca287db12720b4ee480'), + ('\xc79487275ee6d612f9d888d269dc8a6ba5e5b104'), + ('\xc7987bd205f8268bc553d33e0176d04e9ed1d1b5'), + ('\xc798ed4bcf01685bc4901d4e007851ef5feb5217'), + ('\xc79a19164de732384dc4dbda85e71ac6787da0c3'), + ('\xc79d6b5c99bcaa2577f87b71685bd21228292bad'), + ('\xc79dbe1d9fa3b32cab78ef949187c9d9b26b5fd0'), + ('\xc7a56b120f411e71bc0ed8c5a27bad9a96154b75'), + ('\xc7a87f1a0d451631f9ab205d2112bdcd385dbe6d'), + ('\xc7aa49d93f9b2942f76aaad78231e40aa6743d97'), + ('\xc7aaf5fbd2255db676f2df083384d0d50b10b1f4'), + ('\xc7afa92f9ebf87b63d8d6f6fbbf1f71d6583a1ee'), + ('\xc7b7216858b58f4122322ab23389b2ef6c84a179'), + ('\xc7bcf571a49e8f4b24c8bbe32213b1822c57f69d'), + ('\xc7c09325e031ad901c70b2ec598deee585b91196'), + ('\xc7c7462a889d6d9a2752afe8097d3973d33cc4f9'), + ('\xc7cc324aab66d639ad6a8fe04d5b8feac1f9a69b'), + ('\xc7d447fdaca52286bac97e45fb137c905c14eaa9'), + ('\xc7d7ac89364c9c8ea443b9df55b80cc42ab23de0'), + ('\xc7dadc6e1142100154bae3f5044b0929b2d6bd6b'), + ('\xc7ddd3ac7501d326df074f82c0ced0e824fec7b7'), + ('\xc7df248ba94338eb6d78d63610fb43a4000767b0'), + ('\xc7e87f68b9f9ced57fec8798fe02a24ee9e09a2e'), + ('\xc7ea833b86c9b84df6dac67ca0733ba8b37c0987'), + ('\xc7ee88e66ace538591cc432795ae1230c6f4fc65'), + ('\xc7f4e83746b95b0813ffb54c942c1a3d1ab7f70d'), + ('\xc8027f1a8308700d2b24b6617e5e3b4a3c2eb062'), + ('\xc812992c26ad08dc489df9ae0c2cab8f93695590'), + ('\xc816eddbe9c64ca72d475445b241d14ba56522fa'), + ('\xc81cdf7851e64714b4aa1959e8bef4e60cf35422'), + ('\xc828f382896401ac3098ae11cedb3f256b9c286b'), + ('\xc82a19dac99b0a07c922889bfecff6dded00be34'), + ('\xc82b09fd6a374b5137c35d43941b6740304fc187'), + ('\xc82b11b68a42f4a1f151a563cb0721286d9b74ea'), + ('\xc8327be60efb6b29967040ebd890f4f6ea74b318'), + ('\xc834446ff812263c4d53d3b4057aecf7d5b7b2c1'), + ('\xc837e5d2b996807d48cc0fe2bb64a0334114d7f2'), + ('\xc83c2feaca405722968fb4d654fe35138df20309'), + ('\xc83c370da20431de0ce7b296ca67ba6fa77bc1f3'), + ('\xc8409c2b8278e60092f4ab2d57d3c69bd75a1dec'), + ('\xc845b08a3b6c2c4aadce1898298dde70af3883ca'), + ('\xc8473a24786ab016d9c3e717a380910f7cbb0fff'), + ('\xc84a2455630e7eb155a95e958c0798233fa0c8e0'), + ('\xc84aed639588d6f036b1fb256ad897eb80d4cb4a'), + ('\xc85507f6c5b19bdb69403a0425a84ca99336bfe1'), + ('\xc85a41a9746b682e7f6f85913943f3e6850f620d'), + ('\xc85e60563157f9af73d2461dae9504146388fb7e'), + ('\xc86a18f9c557bb2aee1196e9c48ae8310b999703'), + ('\xc874de54940984c1d44ff7ea30a267da4e0653f0'), + ('\xc8783cecab3b869618b8413eb63b721513895f9b'), + ('\xc87e8a8130ac44508d3d549891c5ebed2bca5470'), + ('\xc8814cd47930b7187c77c16f1662d3f056fafeaf'), + ('\xc886d50e8ef05d60d18692e14ee8f99c9132f5e5'), + ('\xc8898ab389be3f30e49d86e2cd08afc39c92e90c'), + ('\xc889a9f5e3bd829b581320ac39d3defcfb5c18ee'), + ('\xc88b7bfb18c7107cd495f3ce29c860faceea93ef'), + ('\xc88d68ef15a73f06b65a5384c9ddb03df46f8dee'), + ('\xc88de884563cac50fda21da5284f964a7063ba95'), + ('\xc88ea1ce7a144de55836a7daf042a630f95a396f'), + ('\xc894fa86243dec71f10bf11c7d0846b8b8b0b08a'), + ('\xc89ac1edbb6c62f1bc0668c77ed49ff7fa24fae2'), + ('\xc89cc452f08d5d16541de9ba74d35894c26f7a1d'), + ('\xc89cd55eefc9be7125f696c874bde1b81bc6b428'), + ('\xc8a3269faf997937c6856b8a06ebfaea4024be8b'), + ('\xc8a33d65abd57601d4a19eb230a95f17493a444c'), + ('\xc8b7efe7587a35ca65e0d423975bff08b31f9353'), + ('\xc8b9ad3b2674bffbb5d1ee62eca56970dbd16802'), + ('\xc8bb63286e656c53379a54a89675bffdea616a91'), + ('\xc8bec48a11032d9f9a71b0ac043ee033d562a6ec'), + ('\xc8bf4f6feb6c3dad227f85c14752b5e6fcc0e40c'), + ('\xc8c4b1eb07f2c9df91155748e2a4991235763ac3'), + ('\xc8c609fafae82e9d9d78351e357aaff51f4ce423'), + ('\xc8d39af62da8712a3d763f165e4f329c176c262a'), + ('\xc8d4652052ba28831ffee70e4f9964bd4b5a8472'), + ('\xc8db5fd050e0e7441590885f5a831c1fbf9162d7'), + ('\xc8e114b4334e049e587678d0845582beab7eb1aa'), + ('\xc8e41daaa36290e7529058ad5c7f4b363d52d801'), + ('\xc8e8676a44f7b8483f5c3efe126d3b44e2015c93'), + ('\xc8eaf7935a65556619bd587e48e3c5511f6dbb10'), + ('\xc8f45a5d66d715a8c6fad4807595d324d5742727'), + ('\xc8f5a04ad4a8472010a537f0ceb3761dad3de133'), + ('\xc8fa146d02c6a9c5c80f1dc269b5cf642ac9cf24'), + ('\xc8fb6d55bc917a65ab50618f39b11a293f7ee1a2'), + ('\xc9007d97b42721c8377075e3215294a2285e173b'), + ('\xc904b8e465e13548addbd1f04a98bbc1dbe98e21'), + ('\xc90da48ff3b8ad6167236d70c48df4d7b5de3bbb'), + ('\xc90dccbfd9bbf682b9389d06ea6f716aa956cfaf'), + ('\xc90eace70b733448af4ed9719e6306b37ac22cd9'), + ('\xc9155b87f89ba47ec4ed72ee3232d9c308a90e23'), + ('\xc9256fd4df356add50520af05821db06974fb59a'), + ('\xc927fe49115a06cca1b7a0f6c9344c9941a21c2e'), + ('\xc92f3b94342207fc8d6a2f779e0ea742a11f7e16'), + ('\xc93b5b587f3ef9d5be957fb3fb5f2bdf244d3810'), + ('\xc9400e3601fd9f4a1e76637b1187f512fd8531ee'), + ('\xc944036e3126fb84efcca1ade786b6c8aa2df492'), + ('\xc94859561ed46b59f47d794ae39d6a5df5708855'), + ('\xc94e6a15c5383549f0db8f609a366c2190622e5e'), + ('\xc94f865d3094f7973627e590a1802a786602d5a0'), + ('\xc95280faeaf9811def2e283bf5a0afe48cd0b4d3'), + ('\xc953e6d2a1cc432180b4951bdcddfae046389867'), + ('\xc955324003d7ca226746d67bdde4a969f7a52a80'), + ('\xc9598177099338ecf1d1e136493ab1c501c40d33'), + ('\xc9652558820212b6181c62f391a50793251f6391'), + ('\xc96bb11ab085da3d3d95747faa10ecff2c94c67c'), + ('\xc979a46c7601da965113e000a5321a0e7729e53e'), + ('\xc979a82052c86bfd47757ff502ec56840528ad8a'), + ('\xc97bcc61e1d978a6d6dd56fc839860405006ed7f'), + ('\xc98148651aa824b4b054d23aeee97f6732be2b98'), + ('\xc982478ebbfa888befdc3b0d721789baca06c060'), + ('\xc9840f4ac8840c287932fad889fe91e44a6d8f58'), + ('\xc989d39d1dce5009a8c130e68384af4cec834c27'), + ('\xc990d22f4de77b22cc76e22a19f3ea5ccfb0c852'), + ('\xc9966d263404fc64e74ee1b980709bdb2affdfb3'), + ('\xc9996587126a948415da4b07eea9dc9ed6d44d6f'), + ('\xc99ad9f7c78c08d06fe1360003f4562dfda48ba2'), + ('\xc9a37150715fbdcd60a3ef87476ecb2d6097a697'), + ('\xc9a612b936b454b9c12aee0dd2775dbf180dd8de'), + ('\xc9b0a8a7daa4b5006427d9ef8f5cb58c6517c43d'), + ('\xc9b3795b687c8454dbe6e8403fffcb6dd3435d61'), + ('\xc9b5643bcf836adf813dac692fbba6ef46a4e5a3'), + ('\xc9b9ffef84259eb68f6d622b984f7153f1bc22e3'), + ('\xc9bc42021643603f754dd379a559380a091dcbd6'), + ('\xc9c3450517459e53dc97b893f72c7373cb1b0d89'), + ('\xc9c4f5d165038f2a527d416451ab0dd1c0073743'), + ('\xc9c9b48eb815e431afbe7b22b16a31d71ae52b24'), + ('\xc9d457f6c9db0ad591d83ee386f5f45ce97daad5'), + ('\xc9d828519ae9fdf244ee3bdcafae4987f185fe3e'), + ('\xc9e6c360631e112456e74aafc7aa9524d04e9c4e'), + ('\xc9e92781fc60a5c63a84b6e2bd2f265c3a927f2c'), + ('\xc9e9c1b804d9edb3015ddd650c45011f9f918dff'), + ('\xc9eabf5a8b9c0d9ef89651e018dfdc6114c6d8e6'), + ('\xc9f6202064260e93cf04c012edd59b88eaf7506a'), + ('\xc9f9068ac1a03e23cb57e03578782e48d3510425'), + ('\xca055fa1c1278c8f5eeedab94b10abbe88c8f0d1'), + ('\xca06449b6c787b3746d5bceede66bf0ffa6be61a'), + ('\xca06ae3f5b1ccad28cc2b0d7c1c743626d253506'), + ('\xca078a40ca19855ca7aa631c2d417707fcf2d9aa'), + ('\xca080e73b31337118044be37db630a8dbc3eb270'), + ('\xca13c2c56fb2bdacebd4c917de7069763185bbf2'), + ('\xca1477c1de4e7d91bf333db1f9d0015467095199'), + ('\xca18ce1aafe7f1758c91c354af252e99e2b490e7'), + ('\xca1e3e345aaf15da42f41009cd11cc48ad9b0478'), + ('\xca1fa9b9aca805ddeea6fec74f7975fd979c3539'), + ('\xca2165d9f294e5fa3286945c94e9d427b69178cf'), + ('\xca25bad50f9d1ecd9341948d8b5cb1706ba099c7'), + ('\xca2624e62a1951cfbaf5150e9b270e0f0d213fab'), + ('\xca264bef03bdabadd6f89318dec825094ba4d5ad'), + ('\xca42bc85baa31a6079dc999c354c90c6a5b900ed'), + ('\xca4365dba162b8afe79b3497b27fc442fd6742aa'), + ('\xca449091eecebfd1b01008eee1b85f214e1368ce'), + ('\xca49a75f068531c19fba06eca355b5ca150de7fd'), + ('\xca4af694d67126e35602eaf915be5407a70a1a17'), + ('\xca5512ca90fc1c4e2e5d229dc0fbf42767c97980'), + ('\xca60f2b57c74c06b9a25ca75f349230686655850'), + ('\xca65972e0209f7dca0a3547755641328a80bd66a'), + ('\xca66912fa69ed9fa7843cadce69060e6643acd38'), + ('\xca717e65454299121477cd8fa902fb7d970c70a1'), + ('\xca7a7e15bbc6a63630b43ca5edfa0956307a65bd'), + ('\xca817b6b6caa2295552eef25825218ef8a512adc'), + ('\xca8660aef1c41e6bc0d64a1b8e99c60376c328cf'), + ('\xca9bbf24b235592a7c7c5dbf8680a91ecd1ab961'), + ('\xcaa0cc29964cfffd27ddb5d7750f53bda6c2ce62'), + ('\xcaa39c0f52331858fed3cc34ae69b603c20a3f36'), + ('\xcaa3c03cd409d1dccf061cd65782e69425eb2be3'), + ('\xcaa3e41440142e0ab61bc0fddaada5d799c7d470'), + ('\xcaacfc609c8bac1ef5b70b2e9577ecda2ceba6ed'), + ('\xcab13d15e14dce33a7cdd63565cd24bdbe69b690'), + ('\xcab886a712e4752e7b8774137759faa46cd0482c'), + ('\xcab9d39f6f0fa0f55d26f87674c558b2c5bd4e2a'), + ('\xcac2d13b69b33c7228c9372d0b3cd9f7ae68c98d'), + ('\xcac70f3e9f9b7e6d0ea62a65d2378c516311988e'), + ('\xcac7a49a9e6133fcb79c6f4bc33ff9d64ec8ac77'), + ('\xcace90958e0e337020f1f813f9f85cc63bcb9613'), + ('\xcad569b5cabc7e34c79dc79ba3b671fad9726ad5'), + ('\xcada615618ece87725bedc217366bf9cf685fc93'), + ('\xcae1e02f842efcafc2aa19576ea5bed82265d515'), + ('\xcae463f9b264b05c0d2bff85eacd94a4fbbcbf4f'), + ('\xcae5011ae5a7b77332065693e547bced216a0d1e'), + ('\xcae8a866480c2d5bba93bcf00d9b7bfa1f4327b3'), + ('\xcaeb5c2fa4dfd984aa86681f903d1701bf824784'), + ('\xcaef0d730227b9fbecf9e5a9aa7551908f8609fb'), + ('\xcaf4c437b02a1261175ef2a9d285c81dd1a5e53a'), + ('\xcaf64d0bd1f05aafb1d1aded8420d9b64476078b'), + ('\xcaf97de9005a626b57fb49dd6bef84a9b6c92ac5'), + ('\xcafc9d70a091689c00a60cef89e431f42bcd1025'), + ('\xcafd3bde14ff6cac66e3d28b33d07d71e4ae8781'), + ('\xcb08fbe08370f8aedfdcf42ca9223acec1b30fe2'), + ('\xcb0bcc2c5e015c2d117a5d585bb950319cca2483'), + ('\xcb0f552443cbb7dc4c4f6e2ccc48ba49b6f90473'), + ('\xcb1023517914c0072ccc4693d75d32f095239149'), + ('\xcb108c9fc191c2e12c1824d4f4c6aef9691bd0a8'), + ('\xcb15ca1ee02bc56eb3cf8b8b4e3beb5e0f09f220'), + ('\xcb3a67dcfc292b49881e8675a3bfe09c2c1cddb9'), + ('\xcb3f2a8efcf766f3bd185371955cdc6ae6a84b21'), + ('\xcb49dad91836258c974cc65d139fd3c780a58757'), + ('\xcb4cf34f64375e3d1857ca80294db12c7b71e641'), + ('\xcb4ff4eda655470d5b0a5a2322037679e420127b'), + ('\xcb52a1dd803a0f5a3c2a83ffef08299af017cf46'), + ('\xcb547cc70093200a1774e2c0c1aaaf600c11c69a'), + ('\xcb5947a6d9f07f3a7b05d2118c4ffebe8f9b66fe'), + ('\xcb5b1cc21cf135432af440e6010c0f2c0c03f42a'), + ('\xcb6a7f9efa4e25bc032fd2ad65f2d8bd97738bbc'), + ('\xcb6dc990dc20366e48a3613792ee5431046d2fc7'), + ('\xcb73569947a82ee48d4c56fd54bf3a7d968b160d'), + ('\xcb748ba39f2aa83e7816155cc5639d4f6e0fed4e'), + ('\xcb8230ba48a176176363399636d0628d82d3c433'), + ('\xcb88287ef67bc0fec364f32b9c6b14d60d847ef0'), + ('\xcb8968700f6c75781579386fdfee9c28292af072'), + ('\xcb89c584b2d7374f01b35c65af1a9ce3c9eb923e'), + ('\xcb94811d623a5c24265ad987a06039b6e9b506fa'), + ('\xcb956923a421124dc3a92cfba826bb31549c4045'), + ('\xcba32d7067b90c0fa6339fdc6966a4bf04d1c7b5'), + ('\xcba49e1519453a73c31310841487b12648e05f32'), + ('\xcbabe6a7074edfc9613ee2a214ed14a2208557c9'), + ('\xcbae17389f0a6cd985dcc16665d933a05ff66462'), + ('\xcbb226fd3f99adf64ee48cd2dad15af53d78b284'), + ('\xcbb50ca9d9584edcb5210a6240f8f6412db8b348'), + ('\xcbb5d0072b3ac34e6337f22f528f7ac2ae5c40f7'), + ('\xcbbc40f3e900708846d3ba93cd570c1cd1131071'), + ('\xcbbd8bc1c6391e1e958e9aa1efdc3b4465e2e53a'), + ('\xcbc1793e116a24af6f2f6d92a6b42dc958956caa'), + ('\xcbc3f1f8fc17d8debd6483a7bad869916cf6bd3e'), + ('\xcbc53d586a49754c74801a241e8b7528e7b2bf8b'), + ('\xcbc95f460d84be09c8f60f431876d1edc176ff95'), + ('\xcbc96fa5e5e3336fcff77cc8d946760077af1f95'), + ('\xcbcb20ce09cfe029be5dac2b8fa5b570ea81cb1e'), + ('\xcbcbfcc4a47fc7a2453c05d745a24db91f169ed4'), + ('\xcbce5798eea04c5fad5926cfc50979207ad30c75'), + ('\xcbdc4bb6cc60fb81eda136b48a347cefb3d57b90'), + ('\xcbdfcaf18b1b3e3229d6ab89151e198f5188b331'), + ('\xcbe8fd7176b80565cfc7db9be5c7eecb739a644c'), + ('\xcbe99b831ac6f9fa408405980a6315d51697a12e'), + ('\xcbf0e998e1fe5b2cf6ec1f6825b2253e03d0e9b6'), + ('\xcbf38d84f53829052f2c2f8c46da429bd503703e'), + ('\xcbf5b070f4be19883ddc2c0c659fd03ab2c5eab0'), + ('\xcbfa6ba4abea790ebe5230bbc425250b8b05e874'), + ('\xcbfb4df93abd11f76b7931d23f50371e9d254b16'), + ('\xcbfe229016664c597c07670a54dc652a7528b560'), + ('\xcbfe81789b24d8da898145473f15febaf9bbb8c9'), + ('\xcbfe8e9a01712077ee0bee6cfcdd2950e63de807'), + ('\xcc07dacca39bbe1eb9fd12265acda571639bdbe6'), + ('\xcc0a62e346d9724dba18cf1937c1637512674b28'), + ('\xcc0c6ea8820772c3637f93ae56d57d66e4f415cf'), + ('\xcc1328f3d2b68fa505b947b2603ab6dedc15d2eb'), + ('\xcc14798582bc41ecd92ce660aefd81a43dd46d6e'), + ('\xcc1e30f136e434a522f48aee17e84d739f895109'), + ('\xcc1ec52744658206aa163eda96c89beb3ef75f92'), + ('\xcc22f2e4b0c80301cb9e9df99b07e98d0f671082'), + ('\xcc2ad18a6456282210b78f6ea370e9f728636275'), + ('\xcc343acd53ad2f1322cb3d0432e5f35ca9e61521'), + ('\xcc3b7f9a9b4646ed2ec1ffed41f0ffd79bd32987'), + ('\xcc46b187665dd397fc03ae57209c0633ca33dc67'), + ('\xcc519fec685bab342fd0f46f8b91a96a4c493743'), + ('\xcc540b33812c60f1b3d290a900b3f5e1e4343f26'), + ('\xcc58656db63f45f9149f795e4567bcf9365ec43f'), + ('\xcc5b029c7b1de10b40fde8335eae4dd9da98a0b0'), + ('\xcc648f4f09949345a204176a90a5c2dcb76a4f60'), + ('\xcc722b58c61728bbc19ec039dc2a82afcdc7cd82'), + ('\xcc7960e8a25e6c9a3206f3af5ca0583846c83f4b'), + ('\xcc7b30bb1a3c97e069a00702e1723471b8c8a7ef'), + ('\xcc7c253412dd2768a62150a57562596a3f947481'), + ('\xcc7d7ac3411ed6c68504cb3f49c68844eba3bc1a'), + ('\xcc7f28252e8ffc832f834d429488df67efd9d6c8'), + ('\xcc857c28850c3e3a88c715c04306cc3a866342e6'), + ('\xcc885de886fc583d2e1dbd28eeb02325da265b41'), + ('\xcc8cc34001e54618ec63b1679d332a6a41cd6c85'), + ('\xcc8fa33af46b34d1ddc8285b8d58898fd23c23f4'), + ('\xcc8fb6c3c7e23f0bc297980122cbce3d12bea2f2'), + ('\xcc94f9fa759c4233f5cf6f6096d08b167e39b78e'), + ('\xcc995d563918c79d8c917c6b03472f9c908f7ffa'), + ('\xcc9fa6f149aecb8146bcb96497141e1af7ad34ef'), + ('\xcca4d2c3222a8a17d6c7b094e4bbe728c3d33bca'), + ('\xcca83d6f545afd217525b8f66dd6e663195bfcc5'), + ('\xcca8529c70cceb48ebd94c74a70cdb1f2893a19a'), + ('\xccb7aac69cf949c082689b4e3805cbd8296b9b01'), + ('\xccc0edc580c65d71b876879ffec05d96283070bf'), + ('\xccc43931ab8d232fe422908e532aec15467db635'), + ('\xccc6ad2832073a185da07d26456f3401bf881cab'), + ('\xccd63ee504b0d1c29ed0545bf55dc16d13eb610a'), + ('\xccde26d864aae7acbc36fe86c305767e21d4e01e'), + ('\xcce4cfbb8b494cf6c93b876f6ddd1884878b99bc'), + ('\xcce4d82c049eeaeb74e2ffad1dd72593e14ef9b7'), + ('\xccea68d02d19144a111abc7f1c28bce1891e3067'), + ('\xccec2193fc413158f74b0d1c4d2029ed0a527594'), + ('\xccef645d2d799e214afa393312429e21d451910a'), + ('\xccf65af5762f05000342685cfe7bbdbd50ae4f8f'), + ('\xcd0929a7d7cb09702c19a7c5c0f0347ee28fb82a'), + ('\xcd0d537a6c09bfc27ed09622df264378681d53dc'), + ('\xcd1039382d1e2fc8a0543312627889919d05c9bd'), + ('\xcd1048a8bccd05b67210ae2fccb5a770a6c1cefc'), + ('\xcd15f33d8040d5ff0759d14a8445408e509ebdcb'), + ('\xcd167e33fbd4a84986d242e85ead44e227abff79'), + ('\xcd1a0e04ed4573be9a6f2bbefd99e9413f54168a'), + ('\xcd1c64dfde612847ab4919568e14f4c4de19d4ca'), + ('\xcd1c84628ce5e6acb091ab9da2a3e9233bf43ecd'), + ('\xcd1eacb13f21010391137eddb6085b5ba31fa8bb'), + ('\xcd213e9489ccfae4e0a19a5d4c3b25bdbd8f913f'), + ('\xcd27b9d8d03c6e28ebf0525e617564d9ddad48f5'), + ('\xcd2f8722439812518b3a7a5ba7e68fc8653776e5'), + ('\xcd345d0e5c118d9382ddff17983a5475d97b0832'), + ('\xcd3f43bb0bc958815c46d4781cf26ab75a3c4f48'), + ('\xcd4980c150fd57785ef4445b207d96e0f2570661'), + ('\xcd52fac7ccc0b0806f115925a6d0c860283f030d'), + ('\xcd6d8707602a630db9509b400764ef0aa49fb7c0'), + ('\xcd72297d59b8055b765aed32d60c7dcf49a7d89a'), + ('\xcd7739aa42cbdb20572286bb75900863bc2f2194'), + ('\xcd7c37942f317fe85fed54d75c6ac7b1a0287230'), + ('\xcd81979f37042dc7638bbdfb526706a53314ec27'), + ('\xcd84b9977fca50a488c6f5af267e622f3c2331c5'), + ('\xcd8622e6a4ed8f10bcbd0885f392c512e8faaf81'), + ('\xcd896e28ba22935cd4ea1d7a438ca17ff9410539'), + ('\xcd90eb781873f4856320a1931f70bb3f081bbb59'), + ('\xcd93e4577ebabe20225222ca67fe183aacba3298'), + ('\xcd99e2d0adb2ed279e14dde6fcf8e931338f3bd2'), + ('\xcda2381d7225b0ec1879df97e258b805a3cc8511'), + ('\xcda3397bcd6b688f75a3714e16c6d96b13ff6e47'), + ('\xcda61493ceadce173224d9eb21bda8ecf6d8dc80'), + ('\xcdae3349f840eaad158d2fd2c7a3ca099be008df'), + ('\xcdaf7cca2b9c33a0568e9a8e85b437ed8919b7c6'), + ('\xcdaf7dae9267f71e8fc4f9cd5f349658a7d423b5'), + ('\xcdb05491a5e50b424971236e9d0b111d92f2b9a9'), + ('\xcdb2f3bb6e9b43ae88e9384d7d0099af88f5c98c'), + ('\xcdb3bf5aee63a41630399454298ce088e61f7443'), + ('\xcdb7437004d406c23b771e4c15e449cf1e0ea9bc'), + ('\xcdc1aae7d91b83420741a9ddef89013c7a0fe307'), + ('\xcdc58b5eb4f99c52b05634c65d119a424df37cc2'), + ('\xcdc7703eef41bdd551579050e3a7471e13ff605d'), + ('\xcdc8e8190226fc74eb71f65f419008039c3df6f0'), + ('\xcdc9cbd01b9f02b7db91bcdf15290a8f5de395b8'), + ('\xcdcb1df24aded1108286d0d4d262b249db94eba4'), + ('\xcdcc159cc3f4a0262993c26281b09b86ab8fd1b0'), + ('\xcdcd2465054415efa1c48b9dd130b990391129a1'), + ('\xcde15d34d763810c5554af67e47aa8349455259f'), + ('\xcde40252e53f19c579e8f69d9e6115b1086436a8'), + ('\xcde7eb99ba9efa0641cba5b67bd623888fde5ce3'), + ('\xcdf738d078165b78837f3efc0eee63cfab4584d1'), + ('\xcdfaa8ccb0b000729821cfbf043b8d6bcbef70ce'), + ('\xcdfdca6d628a71c849c3daa78c3ef96a7395c624'), + ('\xce08c1f5cdd8acfb156491adabcc4321a8db5637'), + ('\xce10b38ce35ef3d33452ed08b0199304b75fd821'), + ('\xce10f6bb0bee10d5b21f5fb9e1a1df01d0164e97'), + ('\xce11c9d3c2a23e9c8433fb3b54eae2d8976c0b83'), + ('\xce14306d148b5eb1911c591537c4f603e464a5f7'), + ('\xce1ab7f8805df691a2102678d18d66f9d7a285a0'), + ('\xce1af06b8ad6365357c0e5b0e5f7c2536a2b350d'), + ('\xce1c6619a1764b101eeda20a1af51ab7c2e90e69'), + ('\xce2201bc4cf13b403b8477d3e899cd5cbc96a45c'), + ('\xce26e4b0cd37be6ee2732b0cf62d45d937361b87'), + ('\xce2d186ff228e56776b2f2d9f14f1e9f5ad38eca'), + ('\xce326330877117fec0fde4f95189a5c10016bf2f'), + ('\xce36ecaa854bb6321d6dea3a95af196270ce5826'), + ('\xce391fc7897aabccf4131db73253840addf1c510'), + ('\xce3eba16f9a6accb9ac15afe421ed6cace8d5ae1'), + ('\xce40ff197008b22d448e21a72abb97548b575ca6'), + ('\xce43d0130695c023c0a8e699ab0ba1868a1ed2ac'), + ('\xce44a144389e8a20f67e24bad3394ea557217111'), + ('\xce49998a481e4923db9754765db0445e642fe31d'), + ('\xce5326d958ec0960cfa4fead8bea9d72d7649d6c'), + ('\xce557c935a1d113a41b68fa4e1b2aa2dc43826d5'), + ('\xce5e0e47e6e5ec3ff7e86f2c5c9348414cb093e7'), + ('\xce5fe15a5fe1f1bf420615960f219c8a4a6d7bc5'), + ('\xce656a1d0fdbcf3edab9dea5e34f2fb593837bbe'), + ('\xce669ada69e2862abb493bca97b7612ac00523a8'), + ('\xce699f20258aae039e100a9805c3a02612729061'), + ('\xce6c1cfdd4aea7f8f51b85ab3e53b94d356b5e77'), + ('\xce6ffb269fabbbd6a2d4e242ed2d6dfa26edc21b'), + ('\xce708bacc3ef196c0b35d9a12b53ef685ca55897'), + ('\xce7a3fb0472b6e1b5f1412026b6df5f8a37492e7'), + ('\xce7abda111012be182ccfeed730f1ce1cb6fd215'), + ('\xce7ffa22d91382dfd2e6e9346c75246cb82bb290'), + ('\xce81c6f306545d5a9dc04bab7b4769356f52ccec'), + ('\xce8334849f77bc0b625b5b7038e0da969b6ccfe2'), + ('\xce8bef03d49da2f75a5e38ee2032735770089f24'), + ('\xce91382d451b692d22a9ff135d59addc25fd2215'), + ('\xce93cf7dad293c9740c89897f038e2f13369d9aa'), + ('\xce95e0727d1025840c4901ec99a560094a18e2bc'), + ('\xce97331746616c62ae332de8899a02d28b0e9b92'), + ('\xce98f1f1074f95e43d350487da8cfc074daec1d5'), + ('\xce9d4f48b1524b2920c19b4355b6a63036a075ef'), + ('\xcea352bdbf03732801c875e58e6b3da1924a84f2'), + ('\xcea4f12242d1233b07bfcfa7aec98b779fe7d5bb'), + ('\xceb14a521696c66b5de084ba66cd548de0b1560c'), + ('\xceb1a50863df0c99bf09f0c9fa3c30bb20cac12f'), + ('\xcec118d753d1714cde0f7599561cf68fc561893f'), + ('\xcec734f0e826ca696792ed4394fc98dc9dd20251'), + ('\xced31b00898364ae66e37235b28aca111576f515'), + ('\xced31c44a8ddc050d93309f6cb4b949ff412b8b8'), + ('\xced45153c34f925ed45d7e07739da1e686407b84'), + ('\xced63968125397c987a12b18184bceafc5083936'), + ('\xceda4d8bfd8b40d02188bff3128ec22564c50270'), + ('\xcee11836542dfa0a76b8b0d257365f4a7324a247'), + ('\xcee91999d1156c3f0d8f60b5f9003d247d273a6d'), + ('\xceeef481939455fae5cf38aecd89cadbfa7f42e6'), + ('\xcef260e4d6692e6eada168a32c75284e2a8d3ca2'), + ('\xcef299ba7fc720ca0cc11a93d636852320a10641'), + ('\xcef7ec0eec89f0ed9fe81b08c632fb00850cc09d'), + ('\xcefd0fb54b90df6c0a78b0f6465accb587a32d97'), + ('\xcefe4dccdbb119daef0370f25a00b497308042c3'), + ('\xcf005ef7ddf92a5124aa98c4e702808d3e69004c'), + ('\xcf02ab884618b7714862f05a3db4de2c816e252f'), + ('\xcf033bec31c88947febf9b1b154f0d119990d769'), + ('\xcf04c77149f35bba9cda901c0cce7683076af6ef'), + ('\xcf0935928d1d3c572cd8481f18c0ac0fd065ffe1'), + ('\xcf0a61c3d1f7db0a22eccaebab8c848a1d7cc604'), + ('\xcf0f861d25ce4078688d6404d30bf31206599a48'), + ('\xcf106c7b5bfb4e728cd6fa6e883740b764fbdc5a'), + ('\xcf19152daf18198e89adb666e787f7270921dab8'), + ('\xcf20c09480a67d66ac2ee36adc268ec2341b05d1'), + ('\xcf21a10717421ac432422bda5499d95933df8d89'), + ('\xcf24d645a27cad0b6711567db8af46b205edf7e7'), + ('\xcf25d78c2bed7467ff4b0a2e5bed6a53319be62f'), + ('\xcf2b379eded7c9d5f420cb4a02bce741d5c33bfb'), + ('\xcf3c0a000ea12eb4fc29cde6f275120b9b10c294'), + ('\xcf4453a9b462cc44e4b140308035e7a2dd35d40a'), + ('\xcf45e151bd5d74b65ef03f74e3afedcb841e85d4'), + ('\xcf4a379e4c8ea576cc3cb17deb8785e36e3b2293'), + ('\xcf4ac1c334fd0482a3feccd7a8aa9de6c769abe1'), + ('\xcf53a16eefea342f5a95d33533792d737d91299a'), + ('\xcf56ed3e9da37d2028098ad508024c9506c4bca8'), + ('\xcf5bfff9af583730c0a01b7ecc7d50e065932aa6'), + ('\xcf5c0968b02b78ac0896aba64745fc92145d3cef'), + ('\xcf600ab6556bb074485e623f326a90d1f03593fb'), + ('\xcf60c98a4075ba322df69eb444f8a9163b3ebc51'), + ('\xcf6466a8ac37719c149633a290f66b699e3632e4'), + ('\xcf671d30b5a1b48bbfbe15bcaad73eb8be4c2ed5'), + ('\xcf6d36caf59201b4d2bdeadf0b32614ad8953bd3'), + ('\xcf789573ea10988935f15d4e4ba7b816a043e129'), + ('\xcf816b32f669346dc92634f0d0c95ad6dba085ec'), + ('\xcf83c8a1a081eeba84c731f619adcfdf4e5727a1'), + ('\xcf83f0146091cdd72626ab3339ccddbe8293fd08'), + ('\xcf8a937a8a266dfc99ef50518cbab2032adbc8e5'), + ('\xcf8b2f69a811ceb4890e70411a05a81379c5dc05'), + ('\xcf8e75a571b0d67e606a0cc3235245ccd991e74f'), + ('\xcf90ceb9eaccdb1431fa8d6559b04a37487d2eac'), + ('\xcf986d4e8fca8ee3f537cbd183090ae4b76b21c4'), + ('\xcf9e7f1ecc4b58af3b8e127db458b1797785d814'), + ('\xcfa4f43c95e0d530b1835d2f55bde86a400b9e43'), + ('\xcfa4f7c6aaffc7d77cc9544c3cd93ac7978f3731'), + ('\xcfa9d337f24ecca3b4a7b2db195d010b5aa084b7'), + ('\xcfb4e0d1f39ebe72b4d2ff43319f5d0fd82e5377'), + ('\xcfb9a9a6e356fa994af3573dc67aa46bc8d7cd69'), + ('\xcfbded2809220539caf2090399f472cfc1ab3300'), + ('\xcfc3fa6bd87dc0704e5e6d9d59e2fd34e809d47d'), + ('\xcfcb28f56b5deab88f2abdfcfd44076a35a881fb'), + ('\xcfd79f4d67dcd1772db4708538563ec4dcc9f8de'), + ('\xcfe027b24f7d71ebd0f0de3f0fd67c225ffade17'), + ('\xcfe3d3601df93f12a0f63d07e792f4a19c09f938'), + ('\xcff4737b4d471d06e40e388b1aba5925517a10c3'), + ('\xd00050e2a7a373fc32ac16d91fdc752fc55586fe'), + ('\xd001920c9105c6c86374d8dba6f966f77b350324'), + ('\xd001ceff63f50f4d0392b11928e841ac5a72eeb0'), + ('\xd0030ee581c6969ab642802bf18efad8bab968a7'), + ('\xd00543c3d858fc55d1ef5de2b1bd4431a94ceac8'), + ('\xd00cf144c8fcd620d6e6a7f09ed5dbf68cd32774'), + ('\xd0174bd0cae72e7d11bf0cd0fd7327491ff4bb97'), + ('\xd0178066dff29395b00daeea75d227c53281a92a'), + ('\xd0276aaaa3a36ba32fc668f861c655a6ca995cfa'), + ('\xd02c8289feedbc74674e828a7df59ff062972705'), + ('\xd036f2471101114961ca066ec744b77c4138f4f9'), + ('\xd03c1b117f2ef51dbc2c29842de91a480d193679'), + ('\xd03e8e132d57b2c20aad566e3245ea3a6e885f01'), + ('\xd048d67380b9d59442453b58bdca95dafcb621fc'), + ('\xd04913a27750090b5f6366c5469aab382eea270c'), + ('\xd050255a430a82ad502f986c1f3c885f3dd9389e'), + ('\xd054ef880f69c82c9384904a5fe0c26903c6a28a'), + ('\xd05845343c4b8749733085642e09f37fadfa1630'), + ('\xd05940566b1ca092dfcac14464d157fc6ee36611'), + ('\xd0675590b80e6be539a9853c51aeedfbfb55e328'), + ('\xd069282082ab218feb24f14c9b775e3f79cfbd02'), + ('\xd06a599070f4c5f514a59ae2a4d4d3ac66996705'), + ('\xd07466d88bfb59c1c5e9b13e2f5eece6458679f3'), + ('\xd080aac31848b605b6d6c5c7a51e52f0bad92f78'), + ('\xd08188454264db0c70201e2241c3b66fd935f66b'), + ('\xd087823ede1ca97a95a21d88feb082ddd0468340'), + ('\xd087aef5f30c77b95acce78d661f46f6a35af18d'), + ('\xd08b5cbbd87d961200494c14cb0162934e9a5faa'), + ('\xd08c44c9019a675b1365e4f353e6820d4a4a0085'), + ('\xd09201b031569b05613d6c7dcd3ae30992dc2a3b'), + ('\xd0a3bd0fdb7a1aa72c211c13db1a2e3d7a3f6b34'), + ('\xd0abeebfd376ad122530d15f337b0db7da0968af'), + ('\xd0b608bbd1f86c9ad22817fa2619b27b6fa1404c'), + ('\xd0b60c217f93ad09351ed4d3a80c73e36bd7fe5a'), + ('\xd0bc6ef245fb51ca4168e1265a8fbecee2e5c01c'), + ('\xd0bfa7171379b656a7ff5fa36710c149b9af1844'), + ('\xd0c823ae626bc375e22eb6c8510e905ad6e78d60'), + ('\xd0d0541b1d10513d51322602c4471810f7541e55'), + ('\xd0d2f4efcdd8ace82a3d969627865501743c2671'), + ('\xd0d56cdc9d9b71116b38a0a4fc09eefe63fa724f'), + ('\xd0d6ec6fe6685e79385d96dc6b6583f93c8fe876'), + ('\xd0d9dc6887bfaab9f113baee319dd366f3c6da81'), + ('\xd0dcb815ac8a56916b957cf1dd187c85040e684a'), + ('\xd0deb0593d140dd67bc92d37723fea471fa5e399'), + ('\xd0e83071f3edab7e3f9d8a47cde7e642bd792970'), + ('\xd0e851e7916eb9d9a95331538dd7c7f5aa1e233e'), + ('\xd0e87305bfdb4728a9120e49a481728401a5db13'), + ('\xd0e9208cb7ac905cad984fa710b8f62bd2a57a4a'), + ('\xd0f71e711a1539670b5a99de52e08f77a28b4a98'), + ('\xd102d7b442297331e664275d65a21b43e81d322a'), + ('\xd104860b23ba964ba495094ce278d623d159dd96'), + ('\xd10806cbebc2f6d318ba2cc62b080b249ebe80c9'), + ('\xd10879fd28ab2599a7873f61a7a120abd30fcb19'), + ('\xd1114c4cb3fcc74ae8348a22f1552ff33fd03f19'), + ('\xd1166f898ce74213f12708e68ef9feb0ea5fd116'), + ('\xd12383204d3f3be9e393f5038144fb33d874205a'), + ('\xd1269c1fb8c003f19ccb8d240eb7a05a015272c6'), + ('\xd128885d267bb5eab7754dc2be3a40de4c14754f'), + ('\xd12a2d6840f91edcc8421b6fa6ab98a2aaddc697'), + ('\xd12ad086f2fa7a099e642b154c5eb8e1f0c08fbf'), + ('\xd12d149724688e70f01da52a838072ca5d7ccc1f'), + ('\xd1344bcb5745a5d09ddf5dc27eb108c0ce425422'), + ('\xd135a7e926ca35106da16294e05d9db8a243c18f'), + ('\xd1367e40b4a31ef13c6957f8bbe2af489b3d4c9a'), + ('\xd1390a0acea24bf59122dc3f1d9b0e1b013f0319'), + ('\xd143df3f6e3aff4a858cc9d572285938d204128f'), + ('\xd14db541422b59862e8c8c94f20a400234e8ad7f'), + ('\xd14f7b1893a1da6463ea11613559c102770bccf4'), + ('\xd15697d4453456eb06cf88e77a49c6a4c102393d'), + ('\xd1592a7742dfaf687c73aa70d4c9aa8291346742'), + ('\xd15a4472855047eab8f3ab1e734aebd2ecfda24f'), + ('\xd15f82f2defcf5cae9826f749f087d87eb1b37e3'), + ('\xd15feeaee1a0595278b3d381d0a6f31f6dcf1d33'), + ('\xd160fac4f29262de952851973c53b5f33a8902fa'), + ('\xd163508170f0273b4876624ff295ac41e5b52e53'), + ('\xd1671486b88602dd869fdfeb40a0690906e74824'), + ('\xd1700a847b74f013e931b9da7cbe2c8dca9cd30f'), + ('\xd1749306bc81b100db6cf2461f05b8ee28a2e6e3'), + ('\xd17dc066fd7f86023b4ea0e33aa75aef95b33857'), + ('\xd18085cb739a960952b44864027fd1068e7f1808'), + ('\xd180a4d62b1706595aedcc7815e569c0b28e5025'), + ('\xd18539a2e30c39ca5c425aadaa55bd6c66ab0921'), + ('\xd185585764829c0f45a96f54b765716f66a70f16'), + ('\xd1858ef813c1ffbd30163b531ae817a7c75636ed'), + ('\xd189d253b27c0b753a153e7da5ec490f9f3b1a11'), + ('\xd197afea5303e0884114243142ce2c505c52358c'), + ('\xd1983feb537c35d4d2a81bf0a8a6b33cff97645c'), + ('\xd19da445579519e76767155311a1ab2810321411'), + ('\xd19e5371bb51a2a06034ba38377458bbae32c183'), + ('\xd1a6c63b34cd8c1bc3c1630e5174ec345bfd4436'), + ('\xd1ac376f6a731833e73451882951fd2c74b87cfd'), + ('\xd1adca90a3443a6b6d188aba45a14462d885e961'), + ('\xd1b40d4260b954761a32d119d1d1a565eb53124b'), + ('\xd1bc3f056fa1b377e52f95e303e569fb22306846'), + ('\xd1c1074480faad2acc7a266619c7ed19dcfadeba'), + ('\xd1c6c1391d5eb5e64a5bbec8efdc38a0fec8c181'), + ('\xd1ce00015605828e4e3ecd6da22376a2b4b42071'), + ('\xd1ce22921f30fcfa276bed6f5f763d5b5c2d2031'), + ('\xd1cfb819b5fe73e658b5d198752695dd97fd3443'), + ('\xd1ddd55d95284691d9b7bc91b109302347279132'), + ('\xd1e055afd416acba8e6b09cb36fe57c7fc5f124e'), + ('\xd1e16ed5a3aae74b4b7faf1937d356f2f340a275'), + ('\xd1e34fd32de2c742e914c206d60b012722148d42'), + ('\xd1e3d23567f337569d46481d1a983312815889ec'), + ('\xd1ea8c62f975afa4da7163cd0da20bdb3fa1bf54'), + ('\xd1ec51d0d242747500d19f872975cebae1780010'), + ('\xd1f1b55b2d09831a59279656a9ec17483458602a'), + ('\xd1f1e57456f37bf9d8a9eb3ba92f174c58aa9cfa'), + ('\xd1f8db784fb246c3f09c7c5003e51724e9a474a0'), + ('\xd1f919809d03e3bf8c12538f393d20b529176b46'), + ('\xd1f9f66281df30b9205d29d60484626cc9ca3a2c'), + ('\xd2037334825139850abeb130b66f86b52e5b48cf'), + ('\xd20ce3681a5487aa415dd29c202726341f24297f'), + ('\xd216d5b70d565e426c2e11b9cff24d16d1bfb873'), + ('\xd2191904e21242e462616687575c67f7321800a0'), + ('\xd219453f9512da881857b99c718e4043033348c7'), + ('\xd21bdfa1137090741cc38db7b35afc65b33c3332'), + ('\xd21e86f35616dcf7ac49d72dfe543525d0c35f89'), + ('\xd21ea7ca54b991f0f2b9601de46b8ca1de053c06'), + ('\xd21f2c7dbb433c9683f6ec78b3ee1d08102eb05a'), + ('\xd22005866d5e9acfa3a38b116ccf43f053bb84ab'), + ('\xd23176a442dedd070f2f675ea372687e8c358c29'), + ('\xd2327553cd516f83ade42cc0b59ea2c132fb856a'), + ('\xd239094b9ad796f48c25a6b9b97a74858069548a'), + ('\xd23a1cd29db57710d435a318346222e6bc5721c6'), + ('\xd23a663404a250b3d8402a96ae1cfc85de9277c5'), + ('\xd242299f6511dc2f8737efe70133057174fb028c'), + ('\xd242d2df6c01ca752f35801691e919f19c2b02a8'), + ('\xd248470b36886f1d90673adbb3acf3af777619d4'), + ('\xd24c16645e240786ecb69e2e9e06c79ebc760f15'), + ('\xd2522db65ef0647bb7d1e6e23d705cebbd22a57f'), + ('\xd254afc942101bce995dd31331fc33df2e3cdfd6'), + ('\xd256c023bfed8078bfe2652de0724d0e512f029b'), + ('\xd25b7adb5eb187ad490f8af4bae6dbc300c6d4fe'), + ('\xd25c083336ae5573ddb8eaa99889e00f21848b59'), + ('\xd261d77022af4551f6cde4281eac0b127eab2258'), + ('\xd262d1446fbd94ecfbedcf4573d8fddbb09f9fe2'), + ('\xd2645da07dc044f4527531724418bc6bb9a2b078'), + ('\xd26e94fcdbba4edf98b6b428f6aa09a2a76f0cca'), + ('\xd277d29253a9f3f25d2c1a1961c04084ce7a460f'), + ('\xd2817134284d4d4eb6a297eede3163e0233a5c05'), + ('\xd2887407f34a75ac490e2ed369aec23e7f192fae'), + ('\xd289e34737f7031b514aaedc406e3350c4780991'), + ('\xd295c6a491dd6adb114f9c533a18db8c1fee5843'), + ('\xd29bcd2c45d410af8cbe9a61d3a25618065a5ef8'), + ('\xd29c4504c6d7f48c2fbb72f3c542037efda501c6'), + ('\xd29d7f26db97df7b56f22d62bfeceed52221d65e'), + ('\xd2a8d6930b43b76f20a58a5ae0eccff739cce957'), + ('\xd2a8ff550913bdba111bd35042770d16d3cdfce4'), + ('\xd2a943edfdd39f182e1a340cd620c1f098b76d2d'), + ('\xd2a975990c8b59bf9af9665f87edbe6bee1849ea'), + ('\xd2af24f2499c6ee2b873d20297d84b861b616c22'), + ('\xd2b66096263c22afd9f2cc97860f1c41e52dd706'), + ('\xd2b78034fe5a485be42c9f0cd816c0b3be8440db'), + ('\xd2bcb8bb0c242e24b28d31ec5e9324231d6b7776'), + ('\xd2bfa7387814cdb805ae5a417abe73df2594768c'), + ('\xd2bfeb87c5faef893bba7c5a5ac1703b2df14aef'), + ('\xd2c56efd183662c8245e2e8558d9e49de8b39824'), + ('\xd2c5dc2a6de39837c9c7609084f76dd2fcedebb5'), + ('\xd2ce890e99e6e51de8da11e87a571e96b594e34c'), + ('\xd2d06ed0ca3c9b6f990a82f41d6d268707d48c81'), + ('\xd2d3b0dffafc5314a85b1ac1e36f830fb061d102'), + ('\xd2d52cdeacfcec89e17242add5dcc33658f0f2e5'), + ('\xd2e55cf7e8ca79febaae31c96a93980cd6d9da78'), + ('\xd2e77fcbe923cd53555095253fb748589fdd9ede'), + ('\xd2e9981cab9c9163f00f1b0f8e66dbae85e2fa57'), + ('\xd2f10baa3c1a14244a2d6d82d98acb7f124c69a7'), + ('\xd2f46fcf94acfa8623cb15966014f15c9c898d4b'), + ('\xd2f6316f21f6a82b2c9da5ebeca04bf7cfb79602'), + ('\xd2fb35fe429462729582a7664b61218241ffbdfe'), + ('\xd2fcf3669b7580ef868b6e40b679f247eb20ae7f'), + ('\xd2ffa19417d34b6ca237f77159cf0819d3bd4fd7'), + ('\xd30819399534d524a3c7cfed0c5177dfd8171d7a'), + ('\xd309a60f777099af224d8ea1d0a902a5207172d7'), + ('\xd312b9e219d70ad5f3d0b7b74a2a8528717d3b20'), + ('\xd317ab53ece98466739e7e7fcbb737ff3039e4bd'), + ('\xd3216dcbbb960b00b3c1107da6d7899b1b828def'), + ('\xd323fbb7f83b33f5b54cbe43035465d336b9692f'), + ('\xd325e1d44f1fb7fd464d2b59819d7070b3460bc6'), + ('\xd3284afe3ffe0caf073e870d5c886825a81c8c40'), + ('\xd3286a8a56f47bac15e01cac771ee8ec43936bf9'), + ('\xd333d4f778ef6e692f153155a0748923d03ac136'), + ('\xd340b6005189814f0a71c389aec4258c9a19b4ea'), + ('\xd34b7440d370fad4bf08bd0afa3cde4ab4c32b26'), + ('\xd34b8a4b112ec3f7eff4a3ee981905c86f9409ec'), + ('\xd34d3a4f36cb9c1c5fa25c36ab518b8381e9a81d'), + ('\xd3528f2671f3a8860b47f370f03d747e017627f0'), + ('\xd35887d6c11463f73e92205152100220c1b562c5'), + ('\xd368968a4c90d7da4a132f79e5d8b9101abba93d'), + ('\xd36e918ed52f7b35d040fca131f549a8aba6e785'), + ('\xd36ff624bcdc6a30bab374f2b9424a68768b538d'), + ('\xd377e6c722bc227f7226ac04dc3618ab176467aa'), + ('\xd377fda06fed93361a77cff363090b091a283e59'), + ('\xd37b4f78513832bd40bb2dfe8918bd74789e3624'), + ('\xd37c67bcfda0339860cd3d230574f99fb24cdc3d'), + ('\xd37e4449faf0f56b6b63753452b9da15088ece6e'), + ('\xd37e94214e4c2cee63191037b8da540b4ce90dd3'), + ('\xd392bf934bac639980cefc90d5f44fbc1c621dd6'), + ('\xd395106ecc640e59b9d6a6a3e580bd486643320f'), + ('\xd3984259d79b9dc48694f0d558f6e9683c8821f9'), + ('\xd39cbe846e531ed7be07eebb11be0bd72d675579'), + ('\xd3a0e14b9cf91d5293d8acda8d0f54a87ac69e3e'), + ('\xd3b432251968fa90f653ee965a949d9d136e71bf'), + ('\xd3b567c9763c86ed5c2ea86044487d4e17a6f6d9'), + ('\xd3c0e93fe748d9846b30351208de6ccfbd938cfa'), + ('\xd3c5c210f9770800c23f18ee01fde0e78f792bf8'), + ('\xd3c5c9238915e9da08ba41ea3d9d79047249c564'), + ('\xd3cfc2dbbd1343a1cf5357410e4db6c53c4a0d75'), + ('\xd3dc5345b080613489d9cd1bbe4833e024f4bf19'), + ('\xd3de705d9a0a1b156863f03b8b45bd4019a1294a'), + ('\xd3ebab56978bd7b75186d4363fa5ad50b8f9f850'), + ('\xd3f01ad245b628f386ac95786f53167038720eb2'), + ('\xd3f0cfbe16e03da00605889a5e887f46cd055707'), + ('\xd40e59ba76f9117de12fefecc84d4ba0e1240098'), + ('\xd411535537861efc79132e479f5cec622df7fa2f'), + ('\xd41577b7159705c9939c45063b5b379467102138'), + ('\xd4169104d105a0613ff78748772339300f4cbe85'), + ('\xd41ca3a06ab99afc3c84512a2c51db94c7375106'), + ('\xd41ee6165baf12a7e7756d997ddbd848ea01d75a'), + ('\xd42fed6c2e6851233b88dc6e93488b1f37099dcd'), + ('\xd431f7c53d64413d8beae797df1aa070fac8d1d5'), + ('\xd433ff72f62cc5938a081c519d37c3e4dc2464cb'), + ('\xd434c66c482867f45fc4eb4bf37084534d1a4d37'), + ('\xd4389cf99476fba475ceb729d5c45213261fa8d2'), + ('\xd43e943312e0f2c653815dd791d93f94f0abd73f'), + ('\xd445e083ae443d3ae466cbe6672efd4b30b86728'), + ('\xd44bc2dd33533ef3d7a2e09f127ef0d49aecc46d'), + ('\xd45afdc8068005173e922c199ca7ccea9bd521e8'), + ('\xd45f36ccb3ef9f0e738d18287233f914144e5097'), + ('\xd461b083ffe319ed0b17f2b181de7013fdd0231b'), + ('\xd465371df19dfe83d0c1a7653a80b04d9cd5719e'), + ('\xd465fc7556826ea8305c3db74ec169bdfab95b0c'), + ('\xd47a8f0def54ee9e7e5112c3989e6321015c803c'), + ('\xd47b43e41eea60af5746ca4df5103d03c2264fb8'), + ('\xd47db81221adf5dfd16620c7d99b372f2d860bff'), + ('\xd47df0a2190d46c929f2fe2c04e5a8d8c54db813'), + ('\xd48748a8e48ca379a6da0499bcc928e8e0eab13d'), + ('\xd488229a2476fbcc119b8608c6b77f425cd32f4a'), + ('\xd48e3f5094540d11d97a23a1df82ec0481caf35b'), + ('\xd497b6ae6382ad3c3e5a8d36662d3a8f83b9865b'), + ('\xd4a42731f71e92ef2535d3fc70589e972121a0e4'), + ('\xd4a4ffa12d24fa2b7ea15849d2ba6889abb7254c'), + ('\xd4ad5c105c428942be0eae28c04c82624ae82429'), + ('\xd4ade638ac45297ea679df4dbd66efb045db7ceb'), + ('\xd4b1fccb309db8121bb72bb9bb73488136d9a917'), + ('\xd4b2340446a4466506ceeadf57605ee5cf8bbd03'), + ('\xd4b787d9130f43e99357dabaa6d839b8e77029f1'), + ('\xd4baf9f8fc4feb30b4cf20c977493478209a4849'), + ('\xd4bb73409f359a98a48a976006ea4b379b8be0b9'), + ('\xd4bc5a51bf8f3734c801596e56db6c2cd416b223'), + ('\xd4bf02ba2c2f9980324e9a0701ea56ea28ae408a'), + ('\xd4c70abb979ab65aa85e1ad034be7225772cafa0'), + ('\xd4cbf4e4e20dd4d0214bfcf8966d009b1a6e81ec'), + ('\xd4d05e35a7484bd0fb77371f36199ac85a13a2ec'), + ('\xd4d2cf7a8db6a7f4c533b17be3f55c09c7571a0b'), + ('\xd4e2761823a2c2c92797b4fc95026211d36ccb17'), + ('\xd4ed4a61434bd30eaa09c651c059aa2cab3d310c'), + ('\xd4f2a9766e66345569e2f4879480a6fdc1ec2824'), + ('\xd4f3848822d8af3ebf0bb32617dae50b236a00b4'), + ('\xd4f63d338d50491975e751e905f22e33cd0fca5b'), + ('\xd504dbe43de4612003a8fcb63a71379b2be44f3b'), + ('\xd505e1ab8622b22d17b79695df9781434bb0aa09'), + ('\xd50959a0a02311ab2f6c82880bcb851eaf3a7456'), + ('\xd50b8ac378e89d9748bbbd4f3dff18efa1baa4d1'), + ('\xd50e4d68cbc1585e6660bc516bf5c7685c646a4e'), + ('\xd50f6dc959a484e95d34b562884331dba883b37f'), + ('\xd51039fcc9f2476166bf9c882f689610cfd7e98b'), + ('\xd51a85be699452441beff95210bc393aae1de366'), + ('\xd528f94a96084e37647a8a0192b90419270a12c6'), + ('\xd52d7180cc07fce1e75a67e0005454fac395e672'), + ('\xd52e04c584c7ebd8b4e626390dd8d09cc33da2da'), + ('\xd532916c3ce93e707b611e5e36a7daf240aaa7a7'), + ('\xd53983933bf240eb2e04d04cff03df8d49f63a3d'), + ('\xd54000907dc67385e75d7b8938505d1b400eaab4'), + ('\xd54c519c9f847fa633d93df9781e7145157c5a9f'), + ('\xd5556a2c9bad07521ae7554ff2d4b5a817a7ed28'), + ('\xd55724127a4a5beaa4f3087f1ea6898b32d1fbab'), + ('\xd55ae0514943f9741c99c5c2242ec20fe1d564e7'), + ('\xd55e905eccb24d1335a823020fe00cea4cd67603'), + ('\xd56278ffb5abb6482970833a4209f84fda3b7936'), + ('\xd565413b6ac0b715494304ee8b7867804f6b1414'), + ('\xd566d0511f4bb07196e214a2a00fb2762768599b'), + ('\xd56fd2973fa803bcd514e0db9d049ae800d4f021'), + ('\xd57088cf6a6a888435d0d94f520947cf0e037b45'), + ('\xd574589aedbaf9e5b1310f06ee1e5b10aef5af07'), + ('\xd57ef822b958365f9152c7adac1e5f5364d1c3ac'), + ('\xd585df0d923dd8cd1387ccfa394ba1bceaaa5e8b'), + ('\xd59564e54b27325cd5940dd04f4656b335f308ae'), + ('\xd5987369a418d16e398ad55eafe9c96853fc5f1b'), + ('\xd5a31283dd491336cc1b96eee0357c5c1002f9b2'), + ('\xd5b1fc9791062f0686ba4a5a062fa02e1b0f8a1e'), + ('\xd5b4eb20a7f7a7032708a569175887b766dba63c'), + ('\xd5bb785c1cfd74aa30988c65b8a1f3762cb68eb4'), + ('\xd5c08e0478838ec5c0a5b48b9b1ee561bd33a9e7'), + ('\xd5c57477f9c5ae625712b65f281690ab6214693c'), + ('\xd5c9bb87fc9f5fad459595e89e61abbac56547ba'), + ('\xd5e58f960c3b7efa5c0195dd225e4ebc1bf017d8'), + ('\xd5e6ffedf209d50259218544e2633fc17ed45375'), + ('\xd5ec214e2e3cf52166b12b97dfbd25dfbf0c6b63'), + ('\xd5ee2d9e590e54f7ed9c2fafce374f49ba0077a9'), + ('\xd5f07e9c3cb2b5a15b3f2361db39e547c9c34b01'), + ('\xd5f1dc0b0c31dea1a1c21ac4aad5d6a52ecb4cd7'), + ('\xd5f3f669fb137424e2fe489e1df62211314e4bec'), + ('\xd6012d7ce1dba8d0f9232b071b24f1b1358dd3d8'), + ('\xd601fc0a63c502a1ee625bf301c28b3b10f5c022'), + ('\xd602ca9d230e1ac315d0b4179c0519caaa50f925'), + ('\xd604d4fa4f7b1809b729ff214b2ef2b7126b2337'), + ('\xd60c31a97a544b53039088d14fe9114583c0efc3'), + ('\xd60f9ac5cfe96fd970f41f9f23235211c48bda22'), + ('\xd61499b0d898071cc2e7406699288d6f69f27320'), + ('\xd61c31fe4cc81e97ccfa75a787a7c85e610a64b2'), + ('\xd61df345ccabece75c58e32787275a6f89f547bd'), + ('\xd61fc79507031253e2fe15a6379c74906bb11d44'), + ('\xd6258f59c303583f3412063592a58dbabbef6a60'), + ('\xd62c9c29a2b67b8d1001a358151b72b808f1fbcd'), + ('\xd63c1e5ae94b6eead3d03dcb8074e336652705a8'), + ('\xd63df043413370b10c2cbbfb1723e4d9d8010ff5'), + ('\xd645695673349e3947e8e5ae42332d0ac3164cd7'), + ('\xd646fd4ef104e18f72b2d67b5061ced649d8289b'), + ('\xd649242bff05ef4afbe561f8eca0233e62a3a0ab'), + ('\xd64c342408b420fd4acd4b42c80f4dc6c768c466'), + ('\xd64d3be2cba545a6af60e7d322c41f9bc0c7bd9d'), + ('\xd64e7fc1df56263b095c4babc6f4ab19dd53834b'), + ('\xd6537ae211ce95b2d851bbf3c48620bd52813e9e'), + ('\xd6544619ed39e7b47d297352bfb4c7a132740a1d'), + ('\xd6544b3f7d2221c2ce553678936d80068a06bb22'), + ('\xd656cf51f263c077fc1df3110c88d1e083d65a35'), + ('\xd6594897a1656a7b9b3098580e4b897000dfbc76'), + ('\xd65cd699aebf2d64d82435251bd486baa5337c07'), + ('\xd65ea8f090567edda7c53f4ea1b9d4c249aba563'), + ('\xd66062dccd891a3fe3d982c899a3dd4eb905f2cc'), + ('\xd6672564f76f09de4e4b081e3fe5feab13362214'), + ('\xd66850c5d8526883a6d1ecd2c60864926231bfc6'), + ('\xd66acb8c1e00c4a844adbe983400d0064e36d4e0'), + ('\xd67015ba2bf0f8d3a458bc89edce111535d5fd16'), + ('\xd676f9c493eb79396cca4cf0dd945588f7180ffe'), + ('\xd6777ccce43e765159b239da98459ba2a0b4215e'), + ('\xd6779ad9b6366028aeb687db1da44a7410f9f6a5'), + ('\xd68782a4edf3e1f5a53c09db45730a8f7d7305f3'), + ('\xd68832148617239bbd70d448c1cd98d0463e9b88'), + ('\xd6893b84cf031b9c46699df8683174971d64d9c9'), + ('\xd68cb2c25292fb13553517a8351473cb0785804d'), + ('\xd68e2e27cad8f8d4c044d822618c26499ce88cc8'), + ('\xd68f499947630d107dae02074b4a70bda5c9f7e5'), + ('\xd69051c97ba0fa02b48690219df4975092e5bb6d'), + ('\xd69b20abbc5929bef677f68085fc1c892797db76'), + ('\xd6a93266f748d606b884f9434ff662fe80b9dc21'), + ('\xd6ad81113e6debc32432075608fe8d143342a035'), + ('\xd6b5773b6c8aa07ecce5e08c20409d7fac694c69'), + ('\xd6b60ff314f29cb3b4e8f84f385ca0d1f06881f7'), + ('\xd6ba9b14c16e807fc5150254b7eca28ec5809a2c'), + ('\xd6c9fe04c97862b8ad28a859a602db4a8b84a043'), + ('\xd6e215fbbb23e2658481cd64fed7ed282a7e6ecb'), + ('\xd6e89ba50d1c95d3122581d80212ba38eb436bee'), + ('\xd6ec3bab4fbf133b486fd7cbcb9e76929945ead7'), + ('\xd6f4a0cd9f43aeb0540031468c77d6bc982ffd88'), + ('\xd6f5aa64ab4f3fbd85fc5c7bae8752777cc2c46d'), + ('\xd6fda86d115b940fe494620a2e6789fa8b3ea831'), + ('\xd6fe8d250b6b8e1f78a56b673a4931f2f4600cb7'), + ('\xd700584d4c9ab104cc31897cd0d8b74821f02764'), + ('\xd70b2b4f224788827ecde9fef87c6ad7dd456d25'), + ('\xd712f7ebc1ea8045703f9bbcd8a86bcba7afd7a5'), + ('\xd717af33ee8220fc935a11ce55c2924fcfd67404'), + ('\xd723deb70dde3c37c949ac2996472a0e6b75f622'), + ('\xd72640b5ea00a910fc9fa7e706992150f5e86442'), + ('\xd72d001ebda0c7702b75e85e2267ac596ab23c6b'), + ('\xd72e8fd12b49d25be134b17a180c0395c03cda0b'), + ('\xd731cb3fc120840996c504fc4e44e609d01a17c6'), + ('\xd7341ea8df58b3e0af414e79c3a084e560fab5b2'), + ('\xd73bdcbe895a4eca294b91527b051ee4134fbb05'), + ('\xd748d8db6b14779aa4656e8c2550f625c4e34bb3'), + ('\xd74b3109406305514d7105af8dd2a10a484db247'), + ('\xd74becf9e3a6f350f79fcaa09a215b83ac3eeb60'), + ('\xd74e5f78efb1b4785e9108f52986c8ffc764fa1d'), + ('\xd7577caccfe30d9972d4d7aaee4b0fb2aa0ef158'), + ('\xd76932b770f62a7827782439eb8dc24304e0f03f'), + ('\xd76a8c007988356fd000f9e3c3c0874722d94be5'), + ('\xd76b0639a10b1c8632ce534aba64dc63bfd00ed9'), + ('\xd77337e26a948b81a004af8815d7b85d36479031'), + ('\xd774972b5d94fbc00aee28d3d229d9a25909cf20'), + ('\xd782501d483738db0771a8077b646b798135165f'), + ('\xd7873290e654c10b7a3a82217aaf44f678446199'), + ('\xd78983735439eecdc9ebbe08cbfd9f503c6930cb'), + ('\xd79141407b51ae8b7e09289ed171f3d1aa23ca69'), + ('\xd795f437d07ab95cb87f7743fa22784874b07430'), + ('\xd7969c39ca9d0b2006c807fa3f2a05afca0c4f00'), + ('\xd79fcf5a6315cf5e70bf5f4987bc87efe17db947'), + ('\xd7a558620febce2e1fedcd231a5a937aa625a723'), + ('\xd7a57cbf69f53fbdde3e8982176238d9fd54b58e'), + ('\xd7a5a343a83b8a53b263af544e86270ca3f7bf9a'), + ('\xd7a72e190171fc946d843f41be8cc4892201a905'), + ('\xd7a730785a1e432b1617fcd8c8e2f734f3af9b8c'), + ('\xd7aaac0b5dd33993cd6c17eb933b7b3f66def54c'), + ('\xd7b0de6ab44462692acebfe084f0372d1f42c355'), + ('\xd7b1261f2e2d69fd07347efa3c2d2b42dd9ec5cc'), + ('\xd7b26902ac2601766485279926486f02db1cf7be'), + ('\xd7b5f2da81783b28fd711b283ccf041c70059505'), + ('\xd7b69405bb5f19fdb73f628d79dd7fb561c7325f'), + ('\xd7b6977fa386ed88a85cb6d58f86b85d2063441c'), + ('\xd7c1b0ee8109e525890e65d8c522bdb89afe2b6d'), + ('\xd7c3ef2c92ebe21628f056ae39c7ba62da4499c1'), + ('\xd7c402d10408fff0fde64786307a1f1e878f94aa'), + ('\xd7c87326d033370f2d3c97fbac15d090afefbd21'), + ('\xd7d4e3470e6d74ca4629afad87113a0c2767a2bf'), + ('\xd7d60bf1a5956a614548401d39b7ca57d98447e9'), + ('\xd7e2af1b928987cfa655e7f949c0982132e92d63'), + ('\xd7e5301f3800aafe49fcfe31546e5cf4329fa6f3'), + ('\xd7e8f5fb3676dd2a463559611d0274b9d5cf22dd'), + ('\xd7eddda25464cbf30d429950d9c159ecbd68bb25'), + ('\xd7ee422db4ae850c18e11121c600564ec0c11736'), + ('\xd7eec8cf6ef0821855351746c700bd6cbfefcab3'), + ('\xd7eed93dd3f4d5464484a24a697e3b114187de9a'), + ('\xd7eedb52bab1afeb257fbae136b2c8f28f70f7ee'), + ('\xd7f10a693effbafac94805e8c7c913b7bda2e470'), + ('\xd7fcb59bed7d7da9a061ea5323a08c0f718a4cc8'), + ('\xd811940ddaeb324938cd2c02968e3584119e3c58'), + ('\xd8123853055c73983af005228f08782546efefcf'), + ('\xd8157ea0f55cac46c96c46364d8f068a6a39d381'), + ('\xd821f14dbed0c5d16db0601989bec72ada789db5'), + ('\xd82323ea1d719807095e8949b4c6e5884cbd8537'), + ('\xd8259f541e374905b71ce3cf9bac4bf5901bf443'), + ('\xd82c47445ea05315515a4c71a898f250ee9fc5d7'), + ('\xd82d1c298de71fef4389d987e666fc45311ba4d7'), + ('\xd82ef514647b885634f7a4a80c12d5c3b2ac4ee6'), + ('\xd83cbcdb835cf942e1ee294c73e0ef81cc119656'), + ('\xd83d5879cb528b6dbec4b92680b7534975a818f0'), + ('\xd83e3f4efe11e343a480d3e4a582c736965cc47b'), + ('\xd83f4a945c2046432674572be252f175d070e437'), + ('\xd8426175c5adca64470145058c3b0db62ddbfb7f'), + ('\xd84c49c753b4b73aa6fdb839498242373699d8fb'), + ('\xd84dfcc5f2db34a6fe5739e92e61f464b61b6dcd'), + ('\xd850f6d98dbe0baec89b6f64117b53841867ca84'), + ('\xd85ab1a6e84159a89b725f2c5551420e22e2c2a8'), + ('\xd85c1d453b2b7f83c84e2562ae0ceadbe976b27a'), + ('\xd85f3a1691274db690b4c092c497f691dfca04ae'), + ('\xd860045a876752853bf61dfe4fc08300920063cb'), + ('\xd86ea475c1d04688848557622802fde0f3bb742b'), + ('\xd87c598d2ada14868ea45791814d884de276594c'), + ('\xd87e1c7c72448938f6e8881c4839203d6b523d0b'), + ('\xd87ecfa4097815f96ad241448bd86260b1489ba2'), + ('\xd88dac6f55b3449c85a5418613d9fb646879ce34'), + ('\xd88e58e7101d6bee3e4be331edaa7c87716f8f8e'), + ('\xd89223ecab289a24c9eda9a19b24b5284ce41c80'), + ('\xd894194e3ac3d2a0870469e4010f62acf5b406c1'), + ('\xd89b5b63d621594e5978562583397f0d0adec863'), + ('\xd8a411fefbc7fa79053147ff920fbd405928f18f'), + ('\xd8a923f09a9944fd3eae728a77d039c4d10173b2'), + ('\xd8a92df14c3b50a9802874f7c28e4dae433a0b06'), + ('\xd8a997f712f533d8d6a62fca8f2e98cc6d891656'), + ('\xd8ad8ef989b7a085adb9b017fc2ac6aa70d5beab'), + ('\xd8b60c0636662805bef4c400d31fc7ca72cb789b'), + ('\xd8bea9b40806ba959b8bc5568e7958a2aa5aaed8'), + ('\xd8c16ff9d709ce1098d8966ceda25f63b6e56200'), + ('\xd8c556dcde98c9d835400da8e702648f04c6ed94'), + ('\xd8c626e1357c5d775cf27618d270d6076bf8e05d'), + ('\xd8c8c4b20c6463304c5b64f4757d11a166eff193'), + ('\xd8cf7c779d13227007a75dd9cf7ad925126e6275'), + ('\xd8d933b8f85f7dcf8b9649be40917494522c5a9e'), + ('\xd8dcb07d9ea9fb4cbf60dc9b5de6bea753d0617e'), + ('\xd8ead42f173d11a9d4b4145ad8b4104808b4a68d'), + ('\xd8edfb3a7a09c40461870f0aa6364ca1b75f9712'), + ('\xd8ee17a5bd418caaa122d8ed3605e369060229b0'), + ('\xd8f6a8d4087a079c7d9cc90d435765719af2d0fd'), + ('\xd8f8d46921aa81abc4c0d27703a8908333ae38c3'), + ('\xd902d99e0483a2653e917d305c866ee496af851b'), + ('\xd90360e7cbe308977e74c3f3ceaf3d07f9acc009'), + ('\xd909e16f32fd46e58292418fa3ef7a751914492c'), + ('\xd90f495104e854780229fdafd201bc72b3c16465'), + ('\xd915809ec61e343aa6e772567072d9ba48aa99e1'), + ('\xd92365a47b6586d0593e6af9ea54a007b88a2225'), + ('\xd92b2585aa746ea427921a6e0d126d40901b1760'), + ('\xd92b70a138c3ad5771a1f7fb329e98b2d58304d4'), + ('\xd92d0475fd86b503757c39aa30123fbee5bc0dba'), + ('\xd9310d81be2dc33ab46eaee4fb5599ff69a4e48e'), + ('\xd931b349249771dc3cd56d72fe9291f39d04c254'), + ('\xd9373be0a07a934783131374405bd0ae10fcb5b1'), + ('\xd93b24c17383c8d051f624145da98cb466560123'), + ('\xd93f925d6876e9308a13288f1af30ba4fbd392b6'), + ('\xd947958381bbc21157708b791ae1cc2e5bfed28b'), + ('\xd9509bc791d1bcdd042058f64e2b1e9bbe069be5'), + ('\xd9540a14c7b829c14be957df855c0c6d1618399f'), + ('\xd965773c329461b19948c63ddf7570ac01171b19'), + ('\xd96c2f3d96e32fbc9da417b9827cd0a9e84bc9fa'), + ('\xd97631232d2b4185de494598d3d6267dee9cdce1'), + ('\xd97a8b03c088ea3870ee96c4b3850f446a8a77c1'), + ('\xd98476d43904fffe9e733c7d4f54a99d6c76e8c3'), + ('\xd98f851a69e6435b027e44e49ef8a471a8339824'), + ('\xd990acea0b1678e2bff9ffc0bb8beaeae9ce7b96'), + ('\xd996113cd207dfee231b04fe99f0ef1206bf36e1'), + ('\xd9998e8475d88b2ba8876d346dc8f850b9a026cc'), + ('\xd99a23175f83ae4a4e6270730548e70e0bccff38'), + ('\xd99b59e1037f7bbedd23d5272b90b524a2dbe753'), + ('\xd99caab1c8338256b7376612a9298a981e8cbae9'), + ('\xd99dca1e47b689fc2d6fccc6bbf9a891472f1ae2'), + ('\xd99e2686519dbaad26eebc598dc3a26f7c30d3b4'), + ('\xd9a606d3dee1db370f23307a1d183256c4c744dc'), + ('\xd9a7df835c5353a821630c5a8c737b486cde4df9'), + ('\xd9ae7e378ae1f6a375802f198b92fef458b83acb'), + ('\xd9ae99f4137a09713411b49585527469d69ffb4b'), + ('\xd9aee04fba321e71c63d2f7e1c722d98b7429a1d'), + ('\xd9b2af6b7f81779f8346988cbbb0b2cd4cca9c3d'), + ('\xd9b5cdc0f7189da8a86973563434f8d1528c0db8'), + ('\xd9b7a1ab0a6911df53ee1e0b89c835fe97e0417d'), + ('\xd9bf24b910b48b05a00dd7d7e9efa89880e92ad5'), + ('\xd9c003b4e5da9a56ba9c861854c5b1c22750240e'), + ('\xd9c08a72afdd46dacb23c729b2188fb3df1b6e7d'), + ('\xd9c5dc4da9184737d5b0fbd0a3c90049f983298f'), + ('\xd9c5fa468db9ae74ba8a0d9cfd0218e03246530d'), + ('\xd9ca122f6e8001e1e9b306179b19a0b86c2ea4ef'), + ('\xd9cbc0f06ff125a61d8997da6e1ee34153e89194'), + ('\xd9ce509af3e3397c62ca1c3f844cb4ca0f793287'), + ('\xd9d383954b41374bfaf4549ff4a5963542ecb7f2'), + ('\xd9d6c00c04443f428282cffa90a3480544e2d4de'), + ('\xd9d90f44d5360fbbddba04293ebdf5ce4ab6180e'), + ('\xd9da62a8f050153edd376c8e0617ddd01e8e5fd7'), + ('\xd9e4437211a9b121e85259f78508e05a68a44b31'), + ('\xd9e719e6186041a911e78ebee11fe8abf7375e80'), + ('\xd9ed84b6535f807b7c9f98e406ec1b91c846d6df'), + ('\xd9ee209cf803d2603a50c60ea216c1631c4facfa'), + ('\xd9f0fdcf2024f7d9794cce529503d16bbc63c34c'), + ('\xd9f2b713e08d50740f13f5b251af1af37d997c51'), + ('\xd9fabe2de9038fa2e51b833781b3c8e79b666afc'), + ('\xd9fe4f9fdd8fdaf62451b602604ccb9f85b85483'), + ('\xd9ffc7b8279fded7e7c725cb02cc0bd59cdb308d'), + ('\xda040be2e954885c1545a1be63d8c48a9f363555'), + ('\xda05a99cf78ea4a599a73b4c29f60b485234b11e'), + ('\xda0b4f40cbbe2014c686ccd93fa7de22dd73824b'), + ('\xda0da798911ba36d76a39039c73156b2540e6eb2'), + ('\xda141f8cd24eab90c3a68c897675a4d5fd2b93ee'), + ('\xda18ad574ce3d386a4350fbad1579d7fdf4d0e88'), + ('\xda1b4b06c0b86b95717eaad7490838d0c1374398'), + ('\xda1ce72e14bd899e897f9efb001232c4cb813329'), + ('\xda200f0fef5f291bd58ea3328d215bd81ecb5366'), + ('\xda27628cc5f6ed59ea96eb8d4be7b95b602cef58'), + ('\xda2d3675641e75b3d5f1cbb94b72b3bf819333b6'), + ('\xda2e1119eafabd3269e918647d80a5317fe57e1d'), + ('\xda308f75064bfba21d4ee894e6ed256df38a8fff'), + ('\xda316b23ef3864a33c89e3caf4ed6b67351d635f'), + ('\xda31ab884839480869fcfd2acac8798fb95817bb'), + ('\xda3504c68c11e4f60df1c35362883eda3af1e8bb'), + ('\xda3af832628d6e34e0d38ce9364e2ec87fd23706'), + ('\xda428a6160ef7d2ce64f9ea00e3993216bf64a78'), + ('\xda43e61aa186211a2f9ea0277878cb5bb14b32a8'), + ('\xda4dfde3f8742b72f665e3bc1979fafc12fc4ac2'), + ('\xda5ca7c46c377430659a09d1c4d73ec4e9a7fcad'), + ('\xda5cd6f4bb61f4bacc6382db6abec59409f340ed'), + ('\xda60f305a6ab2190a78fce5f8981210886248b73'), + ('\xda6525376c5da40b168d8c8f01522c230c2c6e68'), + ('\xda65614076cbf4cda1706c7b992f127401bb6df2'), + ('\xda69d93490436f307c129f5a7ed29c19bbe3b569'), + ('\xda6e3b02850c9b997ab64ec2ae65c8798f083576'), + ('\xda71958c837f33911db0fd0c9963630c4b303cd4'), + ('\xda817b445713467f11249ffc8e8a26ba4f73b47e'), + ('\xda90070378463fceb0b0c34f4a24512a661410a7'), + ('\xda90152a5923e8c040201362c52611752a097d38'), + ('\xda9898a910e5562012c12096a80e17dd2af9a0bb'), + ('\xda9b37b9025d681a717dc777567704dc276dc3e8'), + ('\xdaa0ad172ceb44969587942ea4520b6d2e3a79e8'), + ('\xdaa268a6419fdc2ebf5d32a4b701cf154b4226d9'), + ('\xdaa6e3253032d50fa3066629339db577e2ef82ca'), + ('\xdaae8ce1cdaa34c4a94ae9c829e1368322a9a0c5'), + ('\xdab0419bf761ce999ee0c70bf92888110f5bcb51'), + ('\xdab17ce2858e0b5b198102493798600828e60aae'), + ('\xdab4cf998f15de3722682ec79e7d3e9651bf012c'), + ('\xdabdaf2429efbfd287122a3f4e5b718406d05244'), + ('\xdacc129a1025d97ac2e7bc86338530fb1c2ab32f'), + ('\xdad11495fd4bf825fb62379221ee58019e936853'), + ('\xdad19ebfdfea10cf514b9e9ba70c34424e83abd1'), + ('\xdad5fffbc68c9cc3fc397fcda839c98a5601cc5e'), + ('\xdadc6ec3bf2ca48cd148a1ab5f5b9e018e2ef0d4'), + ('\xdadfd63ed9476b103dde88ef17019b3fcc3e6aae'), + ('\xdae108539ebc76909a0171910dc0636454741420'), + ('\xdae148a29e10a43e92d2410e9a16dcab1f6937b9'), + ('\xdae2f944264488b0fb72891e2c1aea8eea839cbd'), + ('\xdaecdcff02a454af6d7e848471a69a559a7dcf1d'), + ('\xdaf009c7cbd361a8d8bd08ebd89882af8937afed'), + ('\xdaf109f5bff40f7b69ab75ad900367bc028d5a84'), + ('\xdaf1eb58d97e95ce29d18fd458547ee6f370915e'), + ('\xdaf2459b216d1c2ad87780defea350eabfec0bfc'), + ('\xdaf288058953b739fd0d5f9f26160fd1a4910909'), + ('\xdaf4263c948df9da0f24e225d87babeac1c45da9'), + ('\xdafc77a22e41fbb0d5a90e959d8e1b0063a1c6bf'), + ('\xdb009c9ff1482c4e08b2285ee9ff4ab5cec1f897'), + ('\xdb03329fd82daaf25c8f5e2f0ec547a9a37b0e25'), + ('\xdb0dcef7b25fb33ea5d23f530c32351e4b49cfe3'), + ('\xdb1375d1091088809d832d2d5f270911281b1ceb'), + ('\xdb150a855b6b59e1199d8038f7c2ac7cb07bb4be'), + ('\xdb1a6b9beed6037588f4068786bfe5534b63656e'), + ('\xdb1c2925b8fe21ae0223067a53eb6589e0f2aa68'), + ('\xdb1c646107a3e03fc1ac10071171d7a0baabd968'), + ('\xdb2298b7b09f66cb2c95d099fa0684dc3fb75621'), + ('\xdb27dc082ef63e49b556d91994b5c8f8b24b08fc'), + ('\xdb2a48c5c1032f7bcea5eecbb0f4edf8d905fb84'), + ('\xdb39103502fc91b950cc7217fd415bf29dc062e5'), + ('\xdb3f77bb2350837217c5530854e8fbe744bf8ee0'), + ('\xdb3f93819f85a0c78eed89169ed0afa81cb81e3f'), + ('\xdb433349b7047f72f40072630c1bc110620bf09e'), + ('\xdb465b6982d47f54dc11dd88f94108fa66ddf598'), + ('\xdb4ee164125523f7acd06eecc566b27f563fef25'), + ('\xdb54f20235385c12f41544f224bd545f7f5652c1'), + ('\xdb58183ea206e0f7db92e9b84dcf225f64008bb9'), + ('\xdb5efac3f6417c8a124631f5e88c9c8facc07364'), + ('\xdb623accd5d107160dbfb7f8462c611ce590d1b6'), + ('\xdb627166813a31b9d8cdd2a8b1a81c3987521eaa'), + ('\xdb63cf1f0d41606694bcca3bc595309f17ce2860'), + ('\xdb6832afab38674570a625db3f85f9d4548399a4'), + ('\xdb6bcc0e7a0c6010362fd49091216b1709ff629b'), + ('\xdb6c69a2208306ce065a569a9e2dcebe3b8a32b9'), + ('\xdb72f90ad9d6ed4ff17c07423645f2833c734249'), + ('\xdb79cb4c26062f19ef11b3dc27d775833f50c17c'), + ('\xdb815a705312ce8dea9e6f7538bf2b840eb4ae4a'), + ('\xdb84da9bc22811156f568ab76347d8328989ca48'), + ('\xdb906151ffb56ee7d4b04b8f5cae74b622de3834'), + ('\xdb936bdba101108b8f1bb10a0172957f2713fda6'), + ('\xdb99813ff57072e8f7c9c51e10ef9e185589acc8'), + ('\xdba251ee96e92eb5725d468fcf025d91fe169b5f'), + ('\xdba4060d853acda767b0319c42a61cc1fa44ec7b'), + ('\xdba814d0969c597cc45e69abc8b75d52901f0e3e'), + ('\xdba86f79560ad56d8ca3d8800326214c756b48b9'), + ('\xdbaf297276906ee32af61cb55761cd0b1defc9e2'), + ('\xdbb56678d54b65d7830537d4230636d9b6e63376'), + ('\xdbb78e9c1ecd59f0b8860e5d96705ed7c58b9fc9'), + ('\xdbc773bf20f6a5e04306a5f7c8c4c759abf44baf'), + ('\xdbcfb007c2dd38adc319f985f2e0311894b82ada'), + ('\xdbd80da1960ffef76ec60448057182c1314e3b0a'), + ('\xdbdbe00ea2c7410be3f308a0bf795ec79ac2ee51'), + ('\xdbea1ae885412c736b5a5e146dedca1f58109a43'), + ('\xdbf25ee0362d117b3c01d946b69f103428aa4dea'), + ('\xdbfc11367670566cf19ef5c559d5270a972fb8fe'), + ('\xdbfd7de8bf36a90d76187bcbd3ffa98f27b291a8'), + ('\xdbfe1ed585462a3853f01e11dacba34bd17bd933'), + ('\xdc0f477c49f926ab3a836f277004207d08c7247a'), + ('\xdc123605eb95d7c4e78f76580c88b73ff8a66a90'), + ('\xdc12e44cf5f7ba6faa5bb4b26c2e43bc73521674'), + ('\xdc1387c2588c83ebb3e3e7b658a7635f1c70f318'), + ('\xdc188be4446cceea23d5359783f6dd2f371bcb3a'), + ('\xdc1d6ec9a7ac98e94ba0b75e10b1a505f00be6ff'), + ('\xdc2141637ba17c6565207c5e8a705cf0ecae6d83'), + ('\xdc22ebc52b7671c2d28596902c14680ec72e8516'), + ('\xdc245934ef2948cffe290e673cc5667f9e781bf8'), + ('\xdc28120c4cc34c429d63da1e873400d72693ed74'), + ('\xdc2ca1b36b534f4e61f4868e72d7590478950a9d'), + ('\xdc39299ea565395e0b2e78ed12a3c1a55e6587cc'), + ('\xdc3dc61c5340e53c7e3313976af16aa09a40f74e'), + ('\xdc4014c67465a853792315eeed443554c571b1cd'), + ('\xdc4040e97d37fcd11c87a6db81e9530ed68b2286'), + ('\xdc40c37d0a75f5a313c6a77e22d53f0f383ebbfe'), + ('\xdc42269863914ed20a11bf03f795dc2fb599d9be'), + ('\xdc4c8e492b2562d580627b55cdb60c02ffd9afba'), + ('\xdc57ba5dc59a9404c89f1080217e032c413f9340'), + ('\xdc64059bed1edae6ce1132b20c8561691d053227'), + ('\xdc646b1887cb47fa2b92aef2c095da8125aba7e5'), + ('\xdc662ff29f754fa04675e0fa160570bdc2dd573b'), + ('\xdc6cbaeaf7a2294f8f217bc73f8a6625e068ee66'), + ('\xdc71398931d662b8607ea42998660bbc78f5d44f'), + ('\xdc73569abe54ac823ca36ae072de00de127711db'), + ('\xdc7b4ef448282cbf92f2dde763012c09af9333f8'), + ('\xdc7c887bcbd84514901145f660b26020684fdabb'), + ('\xdc7d663996988b718a9e06e60a248f8b1996e9c4'), + ('\xdc863ba1aa51d58eac3b44c717508b8326de4582'), + ('\xdc8a82bc8a66e3b03bfd3d077e0f20c28f4b86aa'), + ('\xdc8b8943e2fc045ccb9894d4be68d6b8512d872e'), + ('\xdc8cb8080720f6dce55f385e196a6a28e8e6b9c0'), + ('\xdc90f8cac78936a52958617c28146a5eacdbd816'), + ('\xdc91e3a08cfc83f664c1a68e7e2d898c0d952ab0'), + ('\xdc93fea6cf3f0473c083ef20eb6a1ef2e6b8e857'), + ('\xdc99d56427314c5b0330159a1be7f66504731d9b'), + ('\xdc9b810989732fd872bde025113ef3697982ec08'), + ('\xdca59d4128fb4abd1b99c0bad27c909e46f06700'), + ('\xdca70eb31bcc55d647e7350fbc83ba2d09dfefb8'), + ('\xdcac23d51298404f3841c131da06a570d960c7dd'), + ('\xdcaee280d2615597a1f3470187873e0ed1382a05'), + ('\xdcb1a2964eb0f0cffacf788753b9c3e6ec31ac62'), + ('\xdcb672a6d52ab12a83fe830575db727efa6f313e'), + ('\xdcbe45349be8ebc4e4c06bc5085c459a466306fa'), + ('\xdcc2cb44f668e201ce7aed04b4dbff844fabf36d'), + ('\xdcc37da67b7bb4eeff77aa1c2766d562ba495480'), + ('\xdcc3cc3d35d9270567d458751c2fc0cc395a9e3d'), + ('\xdcc414cb41bdd3fe440fc15af88aec846809fbd6'), + ('\xdcc4cef815f75e16a562eab2ea2601cc4487c2ae'), + ('\xdcc771dab4cfc7744304b3d52047db1125e2000b'), + ('\xdcc8714f991d4f5ae9847c0e7abe0e400bcc7096'), + ('\xdcce63041a4985225371ce9e2e5d9bcae50b08e9'), + ('\xdccfc5251c3a00cc2b97df328e511e986221b3f2'), + ('\xdcd3893fb26bbf63ec4a99a916a5408d2691543e'), + ('\xdcd539c7416233df33b9c19ecdbcd71c6994f829'), + ('\xdcdc0747e47504e94471644afdda99d3dbfe1895'), + ('\xdcdef875fb6d1af5d4dae9a7814e2e064bfeb5df'), + ('\xdce5acee8157e03dc5aac0290d21f6043f118db2'), + ('\xdce7ba84d39090205a6757ee7c271124cb76fbb7'), + ('\xdceed3b00f18b03f6d2c2a7c5fc860958ace9954'), + ('\xdcf0fda50594f23c506d90bd652f195d550ba3f4'), + ('\xdcf68150514c42958d755dc5ec2bb299452175c7'), + ('\xdcf84343d1250dc39c53cd958e765502fe870a2f'), + ('\xdd00556fbaa12fed8d44414f4dc2750f60d314c2'), + ('\xdd0c8229c2dca7a3fe64ae9a13084cec24b02dca'), + ('\xdd0c92666db04aed22e850c94880b8d50e65a34d'), + ('\xdd1040369efb3ffccdb1780459d38c7956e14d69'), + ('\xdd11643acd40c82ea5380bf49d56b34424c19e73'), + ('\xdd13c2070a63fe919e42342648e51ab4251832ea'), + ('\xdd19de1a912f048d5b94cd6c850b95613bc814f3'), + ('\xdd28c87905d60bcdc5e70f1a2ecd5a0be86d268e'), + ('\xdd2920b5a2940ba836d8c2fb0879ae8415201340'), + ('\xdd2a4d584b8aa5dde7a049ee33fa2b2799fa72b4'), + ('\xdd2cda3427a9f2d7238b09e95b2e04ab65e5a58b'), + ('\xdd2d3e0ca8a822f783ffb2020c9070a154350fe8'), + ('\xdd306f4c56c3e449322fa0b259ca4471e3d390bd'), + ('\xdd31c286188921cdbbbab65457e0a288d63d8a09'), + ('\xdd3416e845f7f183b5baf1b38a1e70f9fd13c787'), + ('\xdd35f9c070abe7d352d84579e5453cc72651d90d'), + ('\xdd3eaa0e572b7128e99827f8dd4bea9c8eb8be73'), + ('\xdd410d2ea3608daf65c8776a693f937cd5c7cf84'), + ('\xdd456e3a7c3caa4130839399d08ef5cac1a0b55b'), + ('\xdd4f8b75640aa3a39ac26375d122906da1ab0292'), + ('\xdd523a7b555e3b36857fe26989dd73ed6ad0d366'), + ('\xdd530a5d9c8ddfdc93761ec4f5eef087869b32b1'), + ('\xdd53b6f7bb7927807657b876c707145b0127e799'), + ('\xdd563e48cbc2644d3a8a33b81bbf32742ab2e43e'), + ('\xdd59fa6b8525a67ff4c119e0c6e7406c6c819466'), + ('\xdd5d4a0f5dbf30ff41419fcfe13f4f7f246dee00'), + ('\xdd66431d73443502783aac2f534dc4ebbf1f6942'), + ('\xdd6bc9f3d07c1d84a0681dcb234e74dd0368614a'), + ('\xdd75ae0a6b1d202ecbcf1eb2c443f7f19948e489'), + ('\xdd7f3b6c3f936b49416f3857d082b7fc53fc3f41'), + ('\xdd81b934dd54a58bfa928b7d3ac65883b75031ac'), + ('\xdd87e35dbb8244d831a4862177dcf6e57e3b6c51'), + ('\xdd8eba490b1ff1a5b9fe02c7c0450a2b2204b354'), + ('\xdd9c589ef8bc0a83dc4378baacf571186c8da003'), + ('\xdda1ef9aff10b5329f94be7439de2558b12bfa0c'), + ('\xddb563e381d5bf8caf2888e757f772c12c8cf39c'), + ('\xddb86fd94b9f5702e1b2eb9b7b0756443a024d82'), + ('\xddbba33715c161fde55c0e20e85bc8a69ce0efc6'), + ('\xddc4d6633664bd887f02e591415d9b8e80e4f8ea'), + ('\xddce1b757aafbe19c486df99e2f4da23d8a733f8'), + ('\xddd962011aaba53285da54e34535517afc31d96c'), + ('\xdddc2c819a33fbf7c53fd216b2bbc828b8084395'), + ('\xdde21d0640c8a5fafcd00112dcb1e0d49d948d25'), + ('\xddeb568836fe07b4f2a0491656c070c187b398f0'), + ('\xdded462c1219342add06ee1b0d80823ccd3280c4'), + ('\xddee473e02035bfce6244746d5d2a281616f7ff8'), + ('\xddf649eee81eb9b7996ab611a7b4eb1e737ccb6a'), + ('\xde004750df8d69b4618a91223c9f356e233e6b6a'), + ('\xde00eb791a9c3a3c51f86183a3773cced147ed9b'), + ('\xde036a320885d23d01f3111dfb58b423b6bb8e14'), + ('\xde0f08c68c9a90c7c2fccdc621aecde6971c1382'), + ('\xde0f8dceed9508ab1df46d0c462fabf0d610b4d9'), + ('\xde1389e8659084aa87f44af5a06d8dadee0c46d6'), + ('\xde1414a81ef556dcc36e7d836e87823aadd9c46b'), + ('\xde14468f1e0e5101cb2986df71d40a8300c0925e'), + ('\xde1494c6b8fdf85857a9ea9583d792770591f254'), + ('\xde182f1a6f952dd440ec00229efdd7500d6316e4'), + ('\xde1853021c4832e53278d72788880a9593758fd9'), + ('\xde1f4f9f8a97342a793757d363ed5662b1332bdf'), + ('\xde2027f9f0d50c18de387752ad63618d177afedd'), + ('\xde21ebb252e49e7b3d7c783acaa8410d5170392d'), + ('\xde2b0c4363e676f41730c46eefbd99eeffbebb40'), + ('\xde39d88163604aebd20a91ec5de4c70e65fa4749'), + ('\xde3b0223b3136b81c7f20658714551506afb2abc'), + ('\xde3e6d37b91cafa4a989741a16037f987e5b51d2'), + ('\xde3fff4cf4186b96894a5c1f4480e223d669b9cc'), + ('\xde4661b94ddbcf46de941f3e1ee76938acfa3d29'), + ('\xde49bb190b9f87e4aa63dd82992a6da4033e0cbb'), + ('\xde4cd6ae08f94dcdd7846d68839f2b13e2ef2e51'), + ('\xde4d216f59fa18cbb0cec9c8dd0ec79cca6b66f5'), + ('\xde4fe5eb130413f9a18969eee8ca3b8ee0065780'), + ('\xde586aa77b6e1934353c6e076e17cf9e2694ab9a'), + ('\xde59de46d33b8783b94fcd08ce76ce432b097e97'), + ('\xde63b8c524a2f3e2109e37d494d6d7a60f291310'), + ('\xde6521d2078b2d64b80560608eb11c2b93c7e465'), + ('\xde6645bb258cf9f0b04aa2e32f473ca6907bc46a'), + ('\xde6e7fed5219368ec93b9ba7693b4ec8f8fe9508'), + ('\xde71081cdad094bfc5337dd9fab2f64560e8d86a'), + ('\xde7338dcff8f3a6679f19823a556ccb51597c221'), + ('\xde73a4bd8aa66fe7a97c04a4afcb318752fc7b03'), + ('\xde75958d5032da7c24891ae3c1431cdb6b0230ff'), + ('\xde781dd47cca19cddd1069627cab041f2e87de38'), + ('\xde7a0c37c1e51c9d4d8c2dc23153ebdf06ab5b29'), + ('\xde82d0a96ffeb02a13870b10d3df188b08dd5efa'), + ('\xde8381932df5176d8b7bce92940b1222def37c35'), + ('\xde85999f83588557e72cfc243b159b4e225c50bf'), + ('\xde8631598d4f33ff543cb7f119c302c3028e0c9b'), + ('\xde8772b3f399d9847a3c6d7393ef319fa87e1cca'), + ('\xde895c9420b8df7796a4f01cce768f1a5330e6d1'), + ('\xde92c2926878c022f62bfa457dd39262b2aa9169'), + ('\xde97ffaad7f68cf6f1d12cea62d89ddd7e64ba5b'), + ('\xde9872f1ba42f3f64fb2b0ebc5cea55cda550479'), + ('\xde9a62894368f61d3e659984bcd9ab0f0590d69f'), + ('\xdea893dbb4c54aafc83b24abd3d34ba46ef92c95'), + ('\xdea9a4ccae7011b082f70e7da975671378ce07dc'), + ('\xdea9a9e38258e040504ca9cc40b6b98fe4925b60'), + ('\xdeac24c1aa759924339a58149bb2e63f5f92ba64'), + ('\xdeb6fea57b5e3d001234ec6c3e6e60a5ea4ce4b1'), + ('\xdebbfd82faeaabdd2a728a508e78c6c2412981b6'), + ('\xdec01c9602819f6ac034bdf400b74dfe6ec312db'), + ('\xdec57470037f3646aa28432a8819ac76812e5b28'), + ('\xdec9a3c80ecf13bc4a1317438b2afc49e1e764ff'), + ('\xdecbeee536299f59545d9df5729277355b6a96f1'), + ('\xded81121b142c2f9766fbf69f389c56b51b3c2ae'), + ('\xdedb023f7b3944896ff9d1d41c3565592e7969c4'), + ('\xdedd31f019e4ce496f7c333ee5b34c608a9bc891'), + ('\xdee5b70c938521bb7ce5f93280ad05d150a0691b'), + ('\xdeedc9f9b0e50cf9a686da1ec53c4beb9e4a0682'), + ('\xdef549e0c96178b8bbff3fb13d3cc5581d4c9e23'), + ('\xdef7f7f212fe11155e220f787e0c607a3cf98087'), + ('\xdf011c42999da4e2a81b160a88932ea9f419fb67'), + ('\xdf0c196f3b5422578e5cf1c13497a277c5da713a'), + ('\xdf0dff33c244466bae382228972842bc39a32f77'), + ('\xdf11d3e5ed49af4533e3e05322b4d46247c28bc2'), + ('\xdf1316d1d1250c463d3611ecc21b903ada6fc053'), + ('\xdf160a4793523a1fcce62296cb637566ea4eecc1'), + ('\xdf1a486c285ea534496292be87556b7fc752c084'), + ('\xdf1bcd80af37a6c2d343e3de05f3c6817c81ca1d'), + ('\xdf1f2b673ebb360c58a5624246cb0860ac796400'), + ('\xdf202c4af6a425beaf250224625332829d2b5b42'), + ('\xdf263b595fc743c0aab260dec3b8a9537a20f50a'), + ('\xdf2f908c9922e6793548e3d0be5fbf8e82e79743'), + ('\xdf36b993ab6a5adb960cc5c0bbbb3f83d79d416e'), + ('\xdf42bdc2e9cec7db38955ba04214f05cf8ce74e5'), + ('\xdf48150cdab76898b55fef5612ec5a84647f03bf'), + ('\xdf49817339fa0c3dfafd99afbffe064260b3929f'), + ('\xdf4c02be79b86581ea9ecee155722819dfafeda6'), + ('\xdf506d99149901846e2557a9b4181f11b6cd31db'), + ('\xdf5108e68013f18f92768852e8ca0e7e799f03de'), + ('\xdf5206d5ef598e6964f0e6d0659fea493418d583'), + ('\xdf5489033c17fa0d89ad18d8a144c42bd39c25e5'), + ('\xdf5c922759b763b268464c34fb9461dd8c3799f8'), + ('\xdf626d39785bb3504443844a2c34c16c31bd1307'), + ('\xdf645ce37612665005323c4a7df8940e85629ccd'), + ('\xdf672670cfc2ca7802c15a65e5ed280fc0f35c44'), + ('\xdf680175bb02854792a9d1b19dd173179d635774'), + ('\xdf6aedf71515300c6d72e3ff9c6564e591605721'), + ('\xdf6ebedc8cf1fb94decc85f3b24fb70027a39cfb'), + ('\xdf6fba2a55a1a08f50f29997478698becd1ecadf'), + ('\xdf7a86040fd72b90337782326c9c28a20dbe7207'), + ('\xdf7ec7aa0142a0942125d934b5f2c44270ff7b96'), + ('\xdf91371fd79791f4688c53796fdfbb4f93e4fc81'), + ('\xdf924773f368aa3609285c312728deb8cd8730cc'), + ('\xdf925becb16c2c771b01075aac52dcb297453f1f'), + ('\xdf9612a3ca5fc7454098fffd72220bb576dce3d3'), + ('\xdf9bba27cec29048b747980417930ac901c75f25'), + ('\xdf9c83bf85e1171c8531805ee5bd99240ebf89bd'), + ('\xdfa09b61d4ca930599340eafd0306c45cce96c2d'), + ('\xdfa141766d91d34e50f79b8d805ee19099148038'), + ('\xdfa356ad84068ea8e765293af409006bb514a2de'), + ('\xdfa3e8bf747458e9cffbc308aaca0c83c00f6b20'), + ('\xdfa4d97d348056ffa39483739ec8b2c32c45e4af'), + ('\xdfaa0847b408d746c2cab00c379fce379fd9a25a'), + ('\xdfac37dee9c27f04f2b72470fbb9063a90d7f9e9'), + ('\xdfb20961c3a0358d54fd4c00ccf3d94dd43bf70b'), + ('\xdfb73fb4721d870db22ef37d31113c42993bd078'), + ('\xdfbd896c0e4e45dfab5ea376514c7e62b46c551e'), + ('\xdfc00c8cef8a4ba5c0c997d9a8a8ce0530e1a025'), + ('\xdfc0fdddbae3f57eb104420326fc4539f83f6230'), + ('\xdfca491aa3f247bebee902303c8533ed4616548f'), + ('\xdfcef59cfb6a9bda5446395817d06d3f100a5ba2'), + ('\xdfd021cad078527471319f8b0d5b419c77a513cb'), + ('\xdfd411e01090c42db94d613752c2117562b74738'), + ('\xdfd5cd0ac8ac942c0611bd80d4c1149fd09e2ade'), + ('\xdfd7d03bea7f277f42b09a4066adc505a9061855'), + ('\xdfd9aed88187f6a4f02dfd2da9563ad294bc12e6'), + ('\xdfe4a6900d6ce15b8741d0fdf9c7699cf3c75060'), + ('\xdfe99aed3ef8633de1f52205ea204acd03718cdd'), + ('\xdff172c49cf6943bddfb79408f1f2f6a72dc4feb'), + ('\xdff2a04b062834638f7b21ccbef0e7693f1ffbd9'), + ('\xdff985b7c0637305eb280a055834ca5850201379'), + ('\xdffb6ae95417fe28f4a01216a59ac2f413b84603'), + ('\xe00804330ffceefb73f3a0bb020d7b78c40a44f2'), + ('\xe0094c9e22e1052c25df611b156f4416f056ca3f'), + ('\xe00be88d8826babf9fe8b8927b4130347bde2cd4'), + ('\xe00cde50cb4557004f8eac695261b86769b77a1b'), + ('\xe00fa8b913e95a2a73c845f61cc1bd68a00e96d9'), + ('\xe0107f87ec82e0312ea902d6d2cc8ccd463d3929'), + ('\xe0115abeb429863679eab4a3ef0c081ddd572ca9'), + ('\xe01a9a9e5973e81598e884d2e9aa277b074eb277'), + ('\xe01e62b8a91f1aa36f54ca87b39f7b36043c4257'), + ('\xe01e7f8f9cea4db81e1885e23bd9c0454e4fafbb'), + ('\xe0263c41c6ff07ec9f70162da775d4ef740384d9'), + ('\xe02874ad03041ec81cf9c6133b43e684665059a0'), + ('\xe02f45c1b3f2635df6556d5e4613ddd9e729b65c'), + ('\xe032635259caeacc46ce7d119f3a1fa828024ae8'), + ('\xe03389db8a94015409e0c5cb35033f15edea9886'), + ('\xe033d4a15242a3405f8742affafa82168f15919f'), + ('\xe03b4150a70c5f22628ca8fd2327d98349513eb4'), + ('\xe04353f850f05378588d9e0e5e9fb7111f0eaf39'), + ('\xe04477f359bfecee269327b8e6abd1c74ee4dab1'), + ('\xe04533c2bb42993c8981e15a377e1efb9e3550a0'), + ('\xe04596a6be0c7ebee9982e8674229691aafe4aa7'), + ('\xe045bfeeb294c69090716909710b9c8ff5f198f6'), + ('\xe0485cdd689bba3bc86c9e724f4f5c6f66dc9d73'), + ('\xe04ac9217e8e30f740d110b342b6a2710bd7f163'), + ('\xe04b13e4825c43142d54c1ba633adb3e5db14539'), + ('\xe04f4a42926847fd772467c7ce3b772242bdc6ac'), + ('\xe04f514018577df394306ce47a98436ec4ad1eec'), + ('\xe05b051da6870f292ab088daad584fd345436c3c'), + ('\xe05d30bcbf88eef2e59afd1d50b42e38ae9be283'), + ('\xe0649452ecdbd9d77fd02847b44fd119372ae55e'), + ('\xe0705770ab0af695b35df7ff1dec51fd130d37e4'), + ('\xe07a906f55fff46551f7eb1441e74a22dc25a978'), + ('\xe07e27cfb28affd0bf97080d8b4a3b791bb9013a'), + ('\xe0862187b25b9f95b7792959e85b7a1b36895b41'), + ('\xe08687982d054d5d236ee82783acd549dd525b11'), + ('\xe0881f7bee95e37cfc38bf1bac0b25a941a540d1'), + ('\xe0891b1b87fa010d5f626c4a67d219b7ce3004da'), + ('\xe0945676610fc2c234dd5942b2a78cf4746577e6'), + ('\xe0985faf19afd26c2e2d0acfbeaaad7654b814ff'), + ('\xe09cf21e5662c8b35041894e63fa0ed61d4a1fc0'), + ('\xe09f2b29361274e2d79a56786b8c931196aca832'), + ('\xe0a269617360b3c6cf78ab5b49e113463f9dac40'), + ('\xe0a4ef0fb9d64f99ab1649ab66a167ab4e9c1e88'), + ('\xe0a5c602860bffc93c2a117d464eafc5d2c40787'), + ('\xe0b17c5909ea118b1c5ba6f273cc3dd87b74c040'), + ('\xe0b8e58e9e7820ebd67ea3345e87766c2a947bfe'), + ('\xe0bd4db151443b547c3fdad831c3a56e3bf20b75'), + ('\xe0c088b0d67e6aca879ba1cddf2186997549aba0'), + ('\xe0c77de7b14bd8945ba218dcc7dbbf0e6d43ef75'), + ('\xe0c97e9adf4c9c58f43624ddca13634681c8ec69'), + ('\xe0ce955ddc686c0c0c68f1cd52538e26e242cd71'), + ('\xe0d9c7d0ee3f3c81d4c83beb4a3f34069022c7ee'), + ('\xe0dba5cbe3adb7d263fdd7bd3d0c0fab7ec6fb00'), + ('\xe0dd5afd14385f276f44277a4bd37eb5f740f0be'), + ('\xe0edfae10197c44fd9fe5842ebb20ad6faaf6370'), + ('\xe0f294a15cffb1424bba720edd27c4c16f3d9680'), + ('\xe0f8bff944143319734bef25509a4774a0431f12'), + ('\xe0fcea27b3cc312b7bad3ce1642149f7832a7481'), + ('\xe0fd8920cb705a4d463c44371debfb03c5d9c75a'), + ('\xe0fd9a294ef09ae3ff488e853ec9ac9bef8b5f18'), + ('\xe100576dcb25a3a3ae6a02acd30806138b352b9e'), + ('\xe100d9cb9b4764337ed1eadc202cd2e9bc0f1b17'), + ('\xe10906ba914f942a7fce5aa3795b99dc08ffaf9a'), + ('\xe115c1914b9c5975e38ba116bded0888bfc3165f'), + ('\xe11a09ac95a29e9ce8fac81b642a8e339c944b79'), + ('\xe120a706077da32e651cfba65efd9be4f127e911'), + ('\xe121606602883c2c9458d5c8b29ec1fb5856edb1'), + ('\xe1221075ce517ef664bdbabb0090a99fccbdc43b'), + ('\xe127fac1a5d8bc58de74bd797a35c2195e55ee07'), + ('\xe12968a0671a66a4e9987a058f3937db148a275e'), + ('\xe129c274482e6930c496e95adfbab8c93cb5b046'), + ('\xe12ddf617a27b300412c219735ace0a5cc0f5d9c'), + ('\xe13076c577c4b2005bfa48abee5d2ed188c48e4a'), + ('\xe13222105a1ce823b2f8e1020fc4f22e8706f552'), + ('\xe13649e473d65aee139e459c9376f6133f06c334'), + ('\xe1386709dbec044dde64ba730be35247445edded'), + ('\xe142efff85956a4cdee630589c12e9dfad51080e'), + ('\xe143786aa40c6f52e816ad35960008296099d8a3'), + ('\xe1446023c1c7bb3cedf40ecebe4cb38f107a6b2d'), + ('\xe14a3db40375e8eca2c734ba7d53ff08caa95d13'), + ('\xe1511a4f663f1a01779e2f25b7a9e99151332be7'), + ('\xe15428dd768ee9a3338ab6361d4de85812e79f9d'), + ('\xe1597bba63c37f4cdfa11f4437bf340aae707f88'), + ('\xe1599293f25f1474c94159465c7114031229610f'), + ('\xe15f56fe2ffccb3840baa13861c9a8fe29d3ea84'), + ('\xe162d1b1d734e16b32d784141ae99369720455f7'), + ('\xe16364835b022587f039f0d9a04024d259ca5cdb'), + ('\xe1644b054aedc7366ecd8bed0833082a026712b4'), + ('\xe165cb5a594f9ec8a2694de8f31c43415322758d'), + ('\xe167ad5bec0a80e823fbb10b821f73ecd86c0514'), + ('\xe16d1ddbd5f94a5d2a53fa9b4cb1d26286395116'), + ('\xe170ab6c69f04dcdc2beb3a5676f61c1068e7ddd'), + ('\xe176aee7827909a0efae43f214aac22ab320ba3d'), + ('\xe1796679e4cee4af00ad5ba22ca61f83f8375f0c'), + ('\xe17bc05223ec31946acece0a86f1852aab649b55'), + ('\xe17e07acda22882c215b4bd501665e993b239d67'), + ('\xe17fff74709bac8dd49e451cc0a43b6e6de4c2d0'), + ('\xe1806e5080e155925de3dce1bc9929bb02d3aaac'), + ('\xe18ebf279bb565a2dccda6ff71d2f91bf60060fb'), + ('\xe19094a8845f3d8db5deb9890059e51f4d21b37b'), + ('\xe1a8f6737feb2a2cefe3b9df4657de67fb65cbfd'), + ('\xe1b48a43afc4680884ebd71202e781220a22f976'), + ('\xe1b545ad6a527c867fc9bc6f8bff8f8e69b37108'), + ('\xe1b7a03596c21dd830c78e1ca5e77ecd39230b5b'), + ('\xe1c48c358b931287744ddbf10e7644761489a89e'), + ('\xe1c502628f39fffa83c3bcd235a3fa2faf92f0c9'), + ('\xe1c5ff96d0df8e3123a02cfaf6f9bcf706ce4b09'), + ('\xe1d0ddf5164f60e3a38ce887891c5f28bfef1966'), + ('\xe1d1e28fbeef2bcd21bab356605f51d849fabbed'), + ('\xe1d8c8e80a9397be3964863c17b0947d78ca852c'), + ('\xe1dd15966b6e77b310988e3a6c735f16dfb33e50'), + ('\xe1ddf32b88008f1e194a661d959437a705f30c40'), + ('\xe1e5d95621ac5484df8ab2f9742e38caa5bd69fa'), + ('\xe1f1421d30b83595c152db913de17a13f8590ba9'), + ('\xe1f5568f2d87d939f387bb2914a4d9e27ac4d77f'), + ('\xe1fc364860f85576ef75e02e11ef6980a07456c1'), + ('\xe202f3f032cadf4cbb2ef23c22b4078e77dc59e4'), + ('\xe2038595b6e1b6fada7ba2e16008db12c9b672ff'), + ('\xe2039b149a82b75cbb704f4cc53138ae9290eeed'), + ('\xe205084f62528669fdd8ae815b7afc943155186c'), + ('\xe20725027fa227a72189cc94e671de1c499588ba'), + ('\xe209fd95777242ba90f4be49966b0c48781d675a'), + ('\xe213000da63f2cbfd71f949464e40424652bb484'), + ('\xe21b82f92bc696279abb50997d601ed2b2045acd'), + ('\xe21bdb756ac5cde6dc10848ed94a4c28db6577fa'), + ('\xe21ec29e36d3e9c646bf9e894241416254dd44ec'), + ('\xe220fc0d27ae9f88343a721858a79d64d1e19253'), + ('\xe223e63a3095b9b08c79706c45e7a0a5f72fee12'), + ('\xe225d9a80893f6447ca74d997f89f378d67e606e'), + ('\xe22d60edda3f579d1f3bc1725004b375ee48ceca'), + ('\xe22d93a89afe29557f8da115b0f5d32fc9d0104f'), + ('\xe2351c7e2ad3adf9b2d5f1aa89a9283c52409aa9'), + ('\xe23e2f25c5ff68e45b3401f4f6174486c80d74f2'), + ('\xe242efa3db037afd1eefd1e93704e2dfeaa6aa53'), + ('\xe2491ff9ef8ad6a906b9ff768a94a0339935163f'), + ('\xe24ba818fda64c18f793a16f5fa5a8fa6bc7251c'), + ('\xe24f5699c9cd8385462968b6ffb88f326c69a1ff'), + ('\xe24fd21346ad85b829047c3b7991d4ab9bbf57c2'), + ('\xe25367ae09437a6d6898bfca14e0264421f94902'), + ('\xe255aa7f391f05e6fcdaab48c92a1511076fb8d7'), + ('\xe258f386b0a55faca9ad786cbc0befcf327fe9c9'), + ('\xe258f96eaed27ee50506eda38b217cd87690be3c'), + ('\xe25ae4b4aada8e26c3d1731b34f97fdd9bd23392'), + ('\xe25bcbcaf10a2729eb3727f5772a025292d4b7ad'), + ('\xe25c3faa869d38d3347f7b0cc2d2d82616d8852e'), + ('\xe25e63527049ae8d95d152f614e4181be11f0687'), + ('\xe26435dad8808d4bd10f790d850d068fccfaf738'), + ('\xe268fb0f58f2cb6df1eb7a01836745498c09f1a2'), + ('\xe26ca898511ce3e0a69c0b8f70ee360d390579d4'), + ('\xe270db4195784f8482f4b1bf33e2f7b9f648c24b'), + ('\xe27204a45f9c68826d597065730686aa70dfeeaf'), + ('\xe2752a6faf6fc920aa993f052780c22b7d73dca2'), + ('\xe27c5710fc79ba91150c5e3a45cbb65e48ad035f'), + ('\xe281bbeb3eed75447587c405a226f3500173d836'), + ('\xe290d8169d05c82d211c23388108fdf68a9cda00'), + ('\xe292493a378774529f0aa7947da63501a157d572'), + ('\xe2a63aef6cd3e806500f9f6ec14df516b9a32ed6'), + ('\xe2a7932ff6a0094a920aafe2357ef7621c59dcd4'), + ('\xe2a7fe847f0817b15c011ac20f69578da8d4e874'), + ('\xe2a857bf289d94bf0dbdc3d1e13c8dcde4deea91'), + ('\xe2b0be47236e61e087f47694ce0b4596887c281a'), + ('\xe2b13773df184cb19cad5971f61f79b76dff6717'), + ('\xe2b44261a48038afe62920561d748d1cf94aa4cd'), + ('\xe2b79aa3aa285e1a7a7377258b0c2c24b940aa76'), + ('\xe2b9709c4b21144408dbafde14aa40c36c813a78'), + ('\xe2c10fc289d8a9153326cdcd37f157b1fda2c62c'), + ('\xe2c9b6b48e8c21d7decfa5fb084814b68a177673'), + ('\xe2cd79ac9f7918921c0c0bafaf99e7de7184b884'), + ('\xe2d0b1fff06da3d5996bdfbc5761971c53b47f9c'), + ('\xe2d6efcc380e437deb5b7bc8ba3e1e91ee542c1a'), + ('\xe2dc7831ffe249c9370f730f39a71c692b1a9687'), + ('\xe2df47fd4bbb067023a22325da38e74d1ce630ad'), + ('\xe2e421eaa34a3538bc5c7464030fcde82a308605'), + ('\xe2e7599921f250128c6f0a0357b397442dbd0386'), + ('\xe2ea6a42fa265ac6458d5a26b9ceb6207d0016c8'), + ('\xe2eef2adfe9f36afb634003902515d251b11e15b'), + ('\xe2f491938f94efb7cb0c69ff7c68724783a65e6d'), + ('\xe301eca90121f05b5b124bbae4a293930e8e8f37'), + ('\xe303dd113b81090425be44e42d6cf0406be349f0'), + ('\xe312a9ad7c986f6b52328c2f92e60b6dc71a328b'), + ('\xe314b195b443285ea6458291b298c9a264e7dfe3'), + ('\xe315f43d3fbcfae1f4f643efad8dc5e090443bb0'), + ('\xe319c9734b362549e59b41d3898d9084dc0e1700'), + ('\xe31ebb8766d0846a0cb6776ef283239897d8f216'), + ('\xe3209b79f5b25acceb994e4e95976ebea5c4782b'), + ('\xe328542dc0f0997c8b64da15d341e7bd34fc5877'), + ('\xe32b9b4624179cfde919be855470ecc7bc95f09c'), + ('\xe3343e72abe2a4b3494b4872d14bb88a11294d30'), + ('\xe3383a2e7c2bc333080da8f73f8b84e42e5e3e77'), + ('\xe33b3dd85c1a4dab8b2ed62e5590b634339a92ed'), + ('\xe33bb7369c3524fa95d9db5ead215ed07333ecb7'), + ('\xe33dde71e7e7e7440c0cb04aaa7b97c4346593e2'), + ('\xe34fb1ed983da3140af4230a5baee4a0ccc3f79f'), + ('\xe3550df162ce2e0dc5fc5f87832d8f069ecffa59'), + ('\xe356ee8b2083ca20651e3d391f628c9b0b883b5b'), + ('\xe36a50415b2050aa502e3b4586aac728c93d45e1'), + ('\xe36bea6af9074ac85062eb2fb79c01898dccea10'), + ('\xe36c78b16d143841bb03b9deb2891a2355b42787'), + ('\xe36dd46a92262b1885b6fe5bda9e0b4872091437'), + ('\xe373f1bf0e8b46af8c68469547243c0b8d9483e3'), + ('\xe375fca1e7fcbb807b0d7e49bc197a1f5ac7d334'), + ('\xe38280dcf2cdb8a5e92574cb9a4240e6c1ff68cc'), + ('\xe38a6916ccd58139c31a8bcc29619ed48c5baa99'), + ('\xe38c2ab76c797a10c8bed19f4b1942a64d89faa1'), + ('\xe392364ea5ca3294bab8a2c75a064c381be9bf99'), + ('\xe39fe2ce98e168a99fd6276ff730372686c4cb24'), + ('\xe3a350bad5896ec2ebcedf7c0358625dff41233f'), + ('\xe3aa186c79acf876a5284014400b49eedaef4112'), + ('\xe3aa3d779c7e15dbccb4f42d57d1a0b186247f74'), + ('\xe3b24728dad5e9b7d6ebba05cd03f1da99a53ae4'), + ('\xe3b44aac73cda04b8775276d72dba946550b49f9'), + ('\xe3baefc9187e4875f1a3920044ae0749bdd58a93'), + ('\xe3d35c5348db0bc8bc5c3f33a66778fa6bc32cfa'), + ('\xe3d49b5fd1516d9c91cb258d2aa4818bd2313358'), + ('\xe3d98b340e27da273c86aa0019ce387213b89339'), + ('\xe3daba4a2bc52131cc4605060f27efb9cc7a4f54'), + ('\xe3e398aecc01be16180e68174c178f9f273c2aa1'), + ('\xe3e8070861ae2f78de323647e5865786889b98de'), + ('\xe3e9ac0208d511b845a975235f8200ba6cec3cdd'), + ('\xe3ee424ff065f5ad64f1b9b0458fc256681a5731'), + ('\xe3efbfb4c68457c3e1234decc17ef12aca1d24b8'), + ('\xe3f2bef1a5ab8809352b81b9fcc050415c9bf5ad'), + ('\xe3fa2912f64799457dcbc1d5185c520ec191f9c2'), + ('\xe3fac55bedb909704431f33f4a674a601eee52f6'), + ('\xe401f6796dbb02dbc901e9016019f98518a5c05a'), + ('\xe40d8f9cb792c371df744cb20635dc6d945bc573'), + ('\xe40e356fd7f113cb2ae39987270820428fe77449'), + ('\xe40ef5c8441ce25beae3ebee5d194b363c77962c'), + ('\xe4227f1bfa9c78a4a17718c7cd84b0a388740737'), + ('\xe4236f1e4a9094298397bf493cfbbf6201d4180b'), + ('\xe4276b4248ed2a3f325f7f9228681daa649ba1e2'), + ('\xe434c98238016996119fbfbec46d579cca9af801'), + ('\xe44460ee81a88534e36fdd15440d237c256792ed'), + ('\xe44917ec5ff118ec1333fbfe32c7fbd94774ce52'), + ('\xe4494e2fe3f0964d477c77b4cace1104f31b67f7'), + ('\xe44f88a1c170c9b948b5596c77ef4fa4ab1015f0'), + ('\xe4571d5b212edba9612c45089f99db6d0decceb6'), + ('\xe457a47c9f5afc2f07a78eb3d4f880807188a7bb'), + ('\xe4673326fee261348e7954ce28553bc064e72300'), + ('\xe46e4ac08741784f33d0b544795cdc82a162d86c'), + ('\xe47d5587619ded0383b432a2b776943e41e0bb6c'), + ('\xe480089bb8e9db0b01b528de76d167559a0c3d41'), + ('\xe484319ef0a4ca7de53e0a691e3a435b6d7c7efd'), + ('\xe485998a2d9c63b2b9594910bc7b107b8da76a96'), + ('\xe49025af910c6ff1a1d1b7f695b6f836b6212108'), + ('\xe498e979d967a10bbe811851775a17d7b46fc7bf'), + ('\xe49946dd06f66b5ddd04fd469cc2ac79153a83df'), + ('\xe4998915ff9e21051715a4f4846ac636a8e2c64e'), + ('\xe49a504ff61eacf92a8a95226036a6a0d8b9bca5'), + ('\xe49ba4616add7045a08483ba4b74e7a0a6341a14'), + ('\xe4a808cc5c4078be4225b2a1ee58cc4932285ec4'), + ('\xe4ab17cb6cbe918de38edd51cf46fdd51814e734'), + ('\xe4abd64b323570dfe84760bd14c02ff812ab439c'), + ('\xe4b6ec6f43d32d6c8734378b31dacc6baa6155d5'), + ('\xe4b87fdbe05861acf9d93b636a631afeeca6b6d2'), + ('\xe4bc0e7902d85359fc990e2db1c151ad05f76104'), + ('\xe4bd5280387dd7f4c3d5aaad0af04f3d516ddde2'), + ('\xe4c3203de72ca9bfaee703b8005a81221ca3c8c0'), + ('\xe4c37ba29d9ec7794c5926f9d598c0b50d6cbefc'), + ('\xe4c7ef832db69b5f9a3753e5745f4dfd14b655ab'), + ('\xe4c8ff4e72c682a940e7c24be4adedc5848f6ecb'), + ('\xe4ca55b2ecf1c2758920e5e97b03d4b10ce3b076'), + ('\xe4d25226ae1ceb0e628e305afd4e4ac085ffc662'), + ('\xe4d359ae916a36e24d55ea11a558d660aef90a31'), + ('\xe4d5c75c93703349e67971500981c25f51f2a8e8'), + ('\xe4dd26d6db9911dfac4ea7c0f348b46c7b7eb6b3'), + ('\xe4df59ea1dfd1b0d3d747f78d6a75c28358dd704'), + ('\xe4eae9ccd582e904597e143ce5dffe175165a9cc'), + ('\xe4ec8ef110690fb54c16fc6b108e390c455acb38'), + ('\xe4ee3220ff0701f10c74347b07c4c7d71cabaa6a'), + ('\xe4ef8ff62d35b4650eddd419a3dccb3c6ba60817'), + ('\xe4f2a942abf2e556843519167919dd86e0a6ad62'), + ('\xe4f460320ec77d2771002f3340c02bae27658348'), + ('\xe4f928b61824703fd87e20a9d1ccf5b9f4f8d305'), + ('\xe4fa8e8783fc3cbcd842e0661fde992dfaba82d4'), + ('\xe4fbd0a6d3ca974a656d4542236312bb7d30abed'), + ('\xe4ff253091b4e6a39b77dab9b9eb526040ad7582'), + ('\xe4ff5cb52b7e07592b30a55bda0f3aed0e4e02a6'), + ('\xe50620e9fe9fb48ac7d387aa66774bfcf7f2b901'), + ('\xe509261dcf788c0889a086db4fd011d5908afedf'), + ('\xe50f63f5df0df3695c6a6e2ced68771d69f94e37'), + ('\xe514928fd9c488e1c621934f2f20d401cf47d343'), + ('\xe521d656a772b091f839625cd1901798d9abf6ae'), + ('\xe5229816b24d1f634780ef3ad53b77abb9ac34da'), + ('\xe52336f8f41c371919e415dbfaaf0c4dea66de48'), + ('\xe5243882edbbaf08c55f72ac4c99bf3dd3330c47'), + ('\xe5264b6ef0a94a95e92381401918b395a6cfc5b4'), + ('\xe528e928b7a313d02483a3979082c199661144d4'), + ('\xe52a3c862c0b06b30562692f708183cc5300e2f1'), + ('\xe530457ecc458febd658b76740728b4935ad084f'), + ('\xe53dc64b9d1b32997e16356c2e733534147dc11e'), + ('\xe5407693762c9fb7f83b63ee47be39999419beee'), + ('\xe543e3ece4b9344549389bb45ab62e35a8846b8e'), + ('\xe547d1ef515165dd62e37b818ac10f7f0f83b776'), + ('\xe54dae89e23df5d332f8afd338823db42aefc555'), + ('\xe54e94525572c2f5b94e1dff9cf1fb31943e6297'), + ('\xe559ff5b15727936b69f0413cdcb372135e02802'), + ('\xe55b49be3d5a50793f53aa71e4780b0a255d5493'), + ('\xe55fd62a4cec2265dad4763dc541417b87a7769a'), + ('\xe56a544c7adb662e3b97777a85b8e513ac23eb37'), + ('\xe56eecd1faf22606d088c6fbf99379b95616882d'), + ('\xe5752418c8b58d0271ce498d1e02a32ddcc8dbbb'), + ('\xe58089d4785fae5c1e7d24d059e3a76618de8a97'), + ('\xe5834778bbdc5602d1980acee57cb4ff18a7b058'), + ('\xe58cf97e364613c7bad50ecba0d3c7f4b89b6609'), + ('\xe595cab49b1944b2d6925483ab813413f991f89d'), + ('\xe59a06477fa50f4ecdecba553f2491af95801b82'), + ('\xe59d2de059e5927a59115cebb07a67856b14be5c'), + ('\xe59e1810dea7b177e5a0e342188a617ea142e2aa'), + ('\xe5a423ab9d526976f85dd58021c7115ae2cf8efc'), + ('\xe5a43bebb4aa492326b81d4f2fb5cdbca556f149'), + ('\xe5a6cef276d8b365bba0d56ce50fda49e11ab8ca'), + ('\xe5a736cb57799845d557488a4170e15fa472c1ba'), + ('\xe5a9599b7525a5f87aafcd048de9e1dae1d9b0c9'), + ('\xe5b7b10d40251bf4990c1b286da259909a57bfdf'), + ('\xe5b868d94a761b3775a66f4cf97cd2a2c287197b'), + ('\xe5bdffca94bf4a4857bedb05916f4497104222eb'), + ('\xe5be2d36076b385780f504d0ef514368c7bf9ac0'), + ('\xe5c40dfed6e187b76f7575234ab5262c39ffe932'), + ('\xe5c5245ca119a770e9fb0e7b9297b904069d02e3'), + ('\xe5c9689af627d000bec52bafd2abc3b4397f0e45'), + ('\xe5ccac3b1f793983a3acf029b8c3a09b06b0abd1'), + ('\xe5d68f096695da86737382cdf1e1cc6db14823c5'), + ('\xe5d763c9b95e0a9db005e59924724fdb1f0398dc'), + ('\xe5dc209443335137a204eed25f99193684c86c97'), + ('\xe5e5c8def67ce3582e05e48473a2034ad545c5d2'), + ('\xe5e9c255d1d0c15d32791af0c609c94c18da8ee1'), + ('\xe5eb609554b78687ff403a49629334a874bd2ed7'), + ('\xe5f3787bffe5f11fec6bcd303cd470f867cf4075'), + ('\xe5f81bce41a989bb855ac745a756ca98c8f1b2f2'), + ('\xe607e5bb4b7f17020dfbc88f5cd6cba62aa736b9'), + ('\xe60923672cdce1561a6a7311f15b4b884774161b'), + ('\xe612e93eae972d1ca6f0cd8502711c53c8975970'), + ('\xe614023e2461babea20bd456b02902b74a24a074'), + ('\xe61a12456ce4430f112fab5146e551839a343de6'), + ('\xe61de97b2ef68fb539505c9f8b393884dfa4f7d5'), + ('\xe6234e85fcee90b02ff453e98cbbab3a32fe80a7'), + ('\xe626ce855f92da9a8583adabbad773ce26af5381'), + ('\xe6278bb9f95e6232ad69829045a0fb20181b7fbb'), + ('\xe62c3f568ef3d7eea45185648a4b3588a497eb35'), + ('\xe62de267a13dd22089ea5ab4343dc68d4b6c7000'), + ('\xe630c5cd4cb053acb9fe22804b76cf9d6e1c3e43'), + ('\xe636e5eb412686fa9684ec1dd2af6f9ecdd6fff3'), + ('\xe63a2656290e1f1784187640fb87c046ba591c11'), + ('\xe63ab95bd571da577c08a579e069da3ce5192943'), + ('\xe63b6f94c39040f6b4915a7e36e356388c73d7c4'), + ('\xe644bfe301c283cb609d0944d49a3d3e7053ea74'), + ('\xe646343668afc435c0b8fb7793e1ddf770771c0b'), + ('\xe64a1d6372a4af756b5c085f82ccb4435ea410d7'), + ('\xe64b25c8ea08cc8fbd221c9c76662e2deeef8554'), + ('\xe64b61c3fae92f0376c3629efa0026a04ec3f003'), + ('\xe65145f371d04239e075d1841b0f5a98238ccc3c'), + ('\xe6555fa072052952d52a279e10ded547fbbc8333'), + ('\xe656614360a61f2fd15b2763155a1f06ca31ff70'), + ('\xe6598ba17279ba13dd91095e2699c30ebf2a570d'), + ('\xe660a510b8b9468bccdcf8c3f5331ee0cb984587'), + ('\xe6624c07f6344e17881fdb54d29d4860227e7103'), + ('\xe66bf306afdc4d696c1ed6b2cb9d2c8d9eaac570'), + ('\xe67140745c816734c380ff63bf631dd0abb548b2'), + ('\xe671a42e053ed10e7978e55cda1e2063925332cb'), + ('\xe675ecf89d8784c3d111cbea327ccdb1405dc64d'), + ('\xe677f86c0c26ad53ba619cbb3e38348c18da8bd1'), + ('\xe67c7f029efa74dd75b55e53db83691382218b85'), + ('\xe67cd85a1b6ea4bc20baa33a92593fbea4a83722'), + ('\xe68ca3d3ce6942c8ce8842bcf3988d0e7eb99dc6'), + ('\xe68fc42dee60a21896c78b01f8f00a1460d31846'), + ('\xe697dff0ed3e8514abdc82abaa2e31b537d4bdec'), + ('\xe69de29bb2d1d6434b8b29ae775ad8c2e48c5391'), + ('\xe69ee64266358b922166964dcfdc5ca955b605ec'), + ('\xe6a12fe8ca1111fbeb3fdc21f08ebccc08a2f594'), + ('\xe6a214f4e146e1ed7df04d174db8652a1db67658'), + ('\xe6a26c68a8c84c2a0ac799b2b5628759da586341'), + ('\xe6a53a0144f7415a6185a09c7e7ecfb4ea9656cc'), + ('\xe6a5c936ea5ad7fac89c4a0e95d6f963eae998fc'), + ('\xe6a6637b0a0b5582dc5ed7998ebd2c357ccad3b7'), + ('\xe6b00d546eb62cd139bf550d997ea13df76511f6'), + ('\xe6b89d3ae722ac5dd7a0ea91623db26e92daf2d4'), + ('\xe6bd8e3eb5d8020420441f4a81555f0e9f57f124'), + ('\xe6bdc492c1f5224beb7e1dbe1834b1f4b9ab5bd1'), + ('\xe6bf2b1264b3aafd76120bf026608d571bca531c'), + ('\xe6c19a1f5cebf4cee8b95e502ea9c127b1f800ba'), + ('\xe6c541417348e930324056bb998a4ec4da37650c'), + ('\xe6c60a11ba703863a5f71e24da9a6dcfec649d76'), + ('\xe6ca3e8c513d9a94ed8b6feb4852bb380657206e'), + ('\xe6dcb742530df063a4d7b7720bab15d3658fe09e'), + ('\xe6e8f33101cd2333f04e67722980df4c01a340b8'), + ('\xe6f2f59b957c9d0b744382c7e523c821c237ca57'), + ('\xe6faee8ec12c641cf4afc2909902072e65621e5a'), + ('\xe6fd6e06ecd8da1f91688feee478fa4843c895ae'), + ('\xe6fe4c0565a572ed45cb7de3ee14fa73b0563ad6'), + ('\xe6fe8a734b35320c9c302701446c7c2f71628119'), + ('\xe72963129eeeed5f327b4bda8f9d44bd337aee3e'), + ('\xe72b5129a0bf0932070ca33676bd6db47cd2a5bb'), + ('\xe72bbd7987dc6f2b620264b380648ec983bfdbf6'), + ('\xe72f925d2ef8f8385f38231b04a7f8fc278bd1bd'), + ('\xe73481079f5456dff5a7704912ea02f95f2d4dd9'), + ('\xe7398a1aadb6474bb0b3253416c67329a9d1a033'), + ('\xe73a1ce800b2f4dc284f94c9017320d0971b9e3d'), + ('\xe73b65789b1c51529abf4256930d4cd866556ff6'), + ('\xe73e38ea22dee2bcddfd6bc92ba0d3e0564df761'), + ('\xe740c307beefda098fd1f35207b80ef3652872d1'), + ('\xe740d18c92e4aa447db1a5dc45ce8f84ddadfcb2'), + ('\xe7415b00934829d09310728baf2eac8ffca11258'), + ('\xe7438e87c18c520e141188e4a2a40dd0c612d2e1'), + ('\xe74636d7342b8b8956472e2eb3a87b59e61dd068'), + ('\xe74ddc2d74d5186b85afe4ff75bbbe5f7d57574d'), + ('\xe7502d06c9835d0a7707f32dcd762adecbfba047'), + ('\xe750c3419aab17e15e9c227e954385de805e3f4a'), + ('\xe75b3cd74f5abd23f7fb957412e01a8dd27671cf'), + ('\xe75dce03358f1e08097998c5727b266cc6d5f184'), + ('\xe75e1bc5507993847b6584fd3b5bc248eccc5d2b'), + ('\xe75e942a9b02f24424c5ab093565ed2a7c183e2c'), + ('\xe762feb112f58903671a55b05150c149d1d0a6d3'), + ('\xe771c03139fad8c38c2fad79161d8d99b706886b'), + ('\xe77a58aa11aeba618958439b5c54cba79efd9e9b'), + ('\xe782c377af23839abb91597ca08e7989becd64ac'), + ('\xe7933cea2c75a35f8252a26fa073955ccc409f66'), + ('\xe793e091253e8c274c93a38dbabe1979927bc981'), + ('\xe79bf3f988bdd5408c36905a08c925377ef2d952'), + ('\xe7ac4751aabbe5aeb72d3cddbf7435cb313850be'), + ('\xe7ac938f421e54dc38df47fb9c2399b5c3376948'), + ('\xe7ae8f55c4fa925fff81fdcd4288eadd443d2e58'), + ('\xe7af2a74ed94f43e9282b6eef38f069865e28687'), + ('\xe7b3afdda949e1cb8a2723de66fe4699deba543a'), + ('\xe7b3b38c69104fd7acb4cf2c5e683fe3c615c140'), + ('\xe7b94b691692d725d251d4565271c8832438005a'), + ('\xe7baa4fc9832abbeebe74531c7370446fd0339cc'), + ('\xe7c0e98355e2014d9a2c76d988f5a884c05a7eaf'), + ('\xe7c32a714d763807c8fb40dd670f125c592c0cc2'), + ('\xe7c4dcd7aad2f3a242fee7bf86cd40490118cccc'), + ('\xe7c7503280acf57aad49645e0d4e206f89dd4ada'), + ('\xe7cb1d5771a3ecce55a49bb063c9595296bd80b0'), + ('\xe7cb5f1d987edbd38e199bc0ff09059b24f66e5b'), + ('\xe7cbcbce89bdb73bd6de79defaa6d5655fbb7b6d'), + ('\xe7cf0898e9d8a54479818f950dd72f4c80be073f'), + ('\xe7cf8c181e76699c44f2bff276f1632dd64860e0'), + ('\xe7d6120dcac0ec82c1750e497d400f40438d996a'), + ('\xe7d6f547af6ffae55e7bff1839088a3892fb6fe3'), + ('\xe7d84482c25490b775f73b40ca9c87325f4b0634'), + ('\xe7d8ae72c85845a6b3bc5617be9e129c0f1fcdca'), + ('\xe7d902cc613ebf5fc3c78c002e0f53b7e4c15451'), + ('\xe7da3ed2c2ed6ac3b5788cf56430321c4389f693'), + ('\xe7db16a9668c2f312d85ee24378c7dd47d8c34a4'), + ('\xe7dde2798d4f39197fd48935df3d135f4e490703'), + ('\xe7e17c091edfa58b7d676399678a3d552701ef39'), + ('\xe7e1fa0cd74847ceaa92fd3c9b4bfc75f329155e'), + ('\xe7e37d90068e184b284b7aa81f743c28b3d371da'), + ('\xe7e676945bf2b70a6139a0e3f635fc1919a38075'), + ('\xe7e7015c9ccdd406d20fc6c0ffba4c5ae386d8a1'), + ('\xe7ed9552c42f4b1906b272ab490eda951bd5a853'), + ('\xe7ef7227230c03bff5c8410bbf6a570b9ce185a4'), + ('\xe7f20d5ffb8b0a575d21665b2b8421d33043e9e0'), + ('\xe7f322e0d9daadc1804380d3461711cf03e94b04'), + ('\xe7f64995cc92ee376e12f1ccebab38e0582d5501'), + ('\xe7f7575d9fa297a86c12a41ed774ea814d67e7ed'), + ('\xe804f6d5905f28aabf4474a149cfc5f75b4e4137'), + ('\xe80878dbdb22c6fee9bd57af1a74feacbf33b675'), + ('\xe814d79cb4bf05df352ba6e5ef2c19c01df5341b'), + ('\xe81629ba61ede13695e05b534e9c48089dab3b05'), + ('\xe818778178879bcb753350ed6c2f27bf1acd9b39'), + ('\xe81af30dd9d367cc8ebdb01a984969340544ec14'), + ('\xe821a98be29736c6b332a0978455fdb8b9a30b32'), + ('\xe8234d8005bb31ce6700dc9a8fdfcb1d9af511a2'), + ('\xe8237d6de1c03ccb1078c9f5973b4036428ffa5c'), + ('\xe827d95c04b957bc1a8fbdb6c7251d27d72d5b02'), + ('\xe82e8623b3778b56bbab19e4386cb43ff7d18573'), + ('\xe82ead8d5ab7fe6f9c25cebf8bcba3ac68691176'), + ('\xe82ee3595012c3a6780f769953f43fb0e9a2a90d'), + ('\xe842a1fa056d2ae22349dd9081ecc738fb268242'), + ('\xe84d649a2bc519af156f416ea5c3407916cdda0a'), + ('\xe85009fa005c7fd9b44a9f82dcaa2a7d96347569'), + ('\xe8524f0e19caac2c2f0c5e852c32e30895604a38'), + ('\xe8536d852f7262a81e71c76b2d2b3bff1401377b'), + ('\xe85508657bbc7e76c7ae9f3474ad08347c5d6656'), + ('\xe85bfc60f2f6d6a5f00c5ce70c17c909ff1e9557'), + ('\xe8603f98622fe76588db04a1915dbe1791ad7b05'), + ('\xe8653aa40193533fb6dba3c3eaf1e519b343840b'), + ('\xe865d282495450e316f0ca2b9014a688609c4616'), + ('\xe8661e7f8ecfaec524aec5adb03d3ba6046cb7b4'), + ('\xe86823f5a2e6bea2cc69d2aa2e267ff6361f5717'), + ('\xe869b49d09bb06726726635c24e8139e503818ac'), + ('\xe86d2f43e83e69cf2fe0d5ea34b2d7b2a5908af7'), + ('\xe872da6e9f1f99fa8e3182a559b87a6707012f03'), + ('\xe8739bcdf12a67b9667b92a58a057855c3fc906a'), + ('\xe8830d68fd3f62f8e9c9cc3eaa6cef3acf6ea383'), + ('\xe88cd40c5588e38caf861ed165c5c50e553f4e7d'), + ('\xe8925375bf302d0c8aed120285be232d4972fd48'), + ('\xe8961ee457ec3fcae1beeabe6723b00b4c9af222'), + ('\xe8972990c1c4d64f1b9baf271fd16401cb6119e9'), + ('\xe89bee5f8f6303aea82ff2f8bd27b6b6c7385034'), + ('\xe8a39be7f01dcfb08bdb3f312ee487cd4d6a3aa5'), + ('\xe8a5948b12dea59513bdaff22d2e7a26af72a989'), + ('\xe8a6cc497dd5ec35a30c81a28e639b372983d469'), + ('\xe8ab1f82feeefe591acce33f9b87804a08c4d561'), + ('\xe8afe2f96be3b84da3c180b8d62001191cc0e241'), + ('\xe8b26bd08d71e636307f671f9c8213e9b89a565a'), + ('\xe8b90d965f26747f60de4e1744b119cb16aa19aa'), + ('\xe8c95c83670ca657b6bd58d317bb2d792b9c7177'), + ('\xe8cae58281e52768b5517bac52f5c3cfcaf061ab'), + ('\xe8cd5d6ea344eb66c00ff7bd7a5e280c851832a5'), + ('\xe8cdf8a4ea910b6d24374b98f2cce76c18879a2c'), + ('\xe8d65cf9dec87517c0bd0d2d919e0050c81246cc'), + ('\xe8d7421d9da2b8e6693c80a448203f484f9daa7e'), + ('\xe8d76a2f0aaea8f2b3f2947b72048b36a4b2e534'), + ('\xe8da0a3303a3f4fdd8d4bf48c4255cfba3c092f1'), + ('\xe8de127a37213e171817b5eed65106ae4f6646b9'), + ('\xe8e04807298a478dbe081fa9e41630ccc1284ac5'), + ('\xe8e547fa907e1bb14b022666b63f5797b3384bcf'), + ('\xe8edf3bb3712619c2401cf7f1dcd7107b78a9de8'), + ('\xe8f1670ae33a8d5c40dbb842aa3ed98addf0c9b2'), + ('\xe8f16b0ee8713f6d37eec3813aa0ecd8d4d8d2e6'), + ('\xe8f17c159dae5dcccce94be5f781d47e3fc86309'), + ('\xe9017dd62e42751387e38be50462182da5677d98'), + ('\xe904c7242d4d0d51c1aa306e6d408f8ec379671d'), + ('\xe90515b3e17c1c54a1bd4e88ae16a0fbff84612f'), + ('\xe905d097f0ec784634ca3c13f581de255db5e68a'), + ('\xe908ce9613f4e8285e6158963eeda9417f2c5e8d'), + ('\xe90be58fb6bbb9d367f67ade9b5db1f2042ee2a8'), + ('\xe90d100f0d48446a32888a9e432e7e1ead4583f2'), + ('\xe910920df4357eb66822c1bd0bd48d2e8e5b21ea'), + ('\xe91574b702cf817e64292f5e6f28ba0be0eb9d2f'), + ('\xe915f503062c3b2d405fb7de7d8f22a759c59914'), + ('\xe919da98fab8aeac70fb6147662c3c8502b81d63'), + ('\xe920ae8aa3e2ed11da171d19a01913fc0d076bdf'), + ('\xe9241ffbb1115d6d51797e16cd0cb27ab0c85c07'), + ('\xe9283cf8e91531466d7ab1e6fc9c6a8b49960436'), + ('\xe935af5c6d85c8c01b68c3bfe4e4d849f19d32f9'), + ('\xe938fa61f0efe513067008059b79ea7c8d9b0e48'), + ('\xe93d22c528cb3c1701f52120ab3fdf6685f78197'), + ('\xe93e302c281d5006455d675c5295740cc2036d97'), + ('\xe9408e3cdeba2e1b574920166a2b8c21c3cff7d8'), + ('\xe9423a031dfc22de94996cbcacbdbeda428c24cc'), + ('\xe94678e67e86d0fbb38ea2a9c3481d8a33e80a85'), + ('\xe94bda22c84f482b46ff209bef423021fd15b83a'), + ('\xe94db509e43b4f1431c308de68e3ebb954ee9d5e'), + ('\xe94e6b059f077325ab03be1516ea882f1ee15a0d'), + ('\xe957acdf0ef5a2e9a36569fb11158245afdeb3f2'), + ('\xe95e5e552e7f1dd00d53a837b06cb2b3305b098d'), + ('\xe96231a3c8b8c8e5ce04be5f82b40772986fb699'), + ('\xe962a3c720781e37949a0d654e11dffff1b6803a'), + ('\xe968972febe6173bea0976153525872c5467c3f8'), + ('\xe96c27fb1a943c44d809be45b4389b4a1a5098f0'), + ('\xe96d8251d2507e3a312f37a0e79f535778de7f6b'), + ('\xe96ec57d7b799aef5d67ddd99db698e60d3c92c1'), + ('\xe970dd65dd11354f93b520154df96e7a90ffbfc2'), + ('\xe972fad8f60fac7c663fbb0f070ad88a31100a78'), + ('\xe973c5e704c922a761b8ab6411b5b086b0897f1d'), + ('\xe973fde962ee257ad31754b9a564a0763c9d99b0'), + ('\xe97914f9812f50d9a55c7174d954f50d9035a143'), + ('\xe97bc4934183f6a06445bc714160de65bf5e5e04'), + ('\xe97d5bd96600ead05efd12e2fbf1918d61a595fd'), + ('\xe97f3d5f4a72a1a3f346a827d10bffde91a74aeb'), + ('\xe98400cb6444fabe29ba4b2dc795ca122c89c7eb'), + ('\xe98f09cbd62dc728420aef5c9037266b5ff06f01'), + ('\xe9906b70adc8066f14d196cb7e9a8c9993a7f072'), + ('\xe9935ef50a8a5e237c954b791434194d63f5a036'), + ('\xe9937f1df2c2acb7b7e5b63b297d1d85c18396d1'), + ('\xe9970167aa9f2c419654918fff8e4d7eb235de6e'), + ('\xe99d39727740fa94dbb06b877879f5795f223fd5'), + ('\xe9a005749885a2043bb7728773f955bfc2489801'), + ('\xe9a11f9ea0fd84f939e19725cca26ab5aa5f7245'), + ('\xe9a1c15c1afcfc02f09aec52192c4c5e6f9b2fbd'), + ('\xe9a6468f4b878a56fcdb5f0856fda6625b831b4a'), + ('\xe9a6f877488e4708e6814f76a50334d71ebfd53f'), + ('\xe9ab78cdce7e43ab8f3d5b7719c3b8568e81fa7a'), + ('\xe9b4ccb2ecec014c74a19dcde91b22ed091b1e6e'), + ('\xe9b53db44ebba69f4cfcdfcf48e6ba321b6a3325'), + ('\xe9b73d090685f9520abeb8da03664220f9043283'), + ('\xe9c581cb558e7f76ab1462940c7dba5e527a0674'), + ('\xe9d37604145e0614f215724198b2caece2e156c5'), + ('\xe9d929e9f3198eed94530cd16425acbd4fec834e'), + ('\xe9d98caa6611d9ba519eea258ca082b0cd353d97'), + ('\xe9db21d3ea7c364808c7185d723d2daf7c92bc6b'), + ('\xe9ebac7ba279dc2678b40b64fd844a1eeee6380e'), + ('\xe9ee442287775b898ca711576c359701ef097bcb'), + ('\xe9ef1dc516162fc95395a661f83a31f7a2c4c729'), + ('\xe9f3708222d19ef00b62447e7854b1ca3eb89f8e'), + ('\xe9f925fde0d2c8fbec61226f9a6b4535aea82e24'), + ('\xe9fb9f6e25b1699c29d4ed441524cef258afb5fd'), + ('\xea0b59ea9209bfa8cdff80b3ea4c2d65127262a5'), + ('\xea1124bab45d27e6c911e0b6914bf2d23a967b8c'), + ('\xea128b894154b610bbe45d5aabf733d7811f5224'), + ('\xea1613c2dc66adf18717276ac56d80d6a46ab445'), + ('\xea16f3753976550d4bd73332c452e3fabb836c55'), + ('\xea1732de64e8312cbea91e9832b35c12aeb88064'), + ('\xea22fddcb6600b1f36f8cd2cb60319c21d9bf59f'), + ('\xea2358bfc546292cef1b0f8ba4d2748fe07e0022'), + ('\xea259f77d166f14651a07ffd17dad18daaaf7065'), + ('\xea39754b499651730ac9d95b5491bababc8716c2'), + ('\xea3d2acca8064228ea62632e88757d6a4a5672e5'), + ('\xea3f35af251955dd2aa1c75501372ea415f37032'), + ('\xea3f402017ddaa231e2b27c3d280abf1b45b9f53'), + ('\xea4446eea39854b31ad94d37d961139f84a1a2ed'), + ('\xea45d446d389ad7d8588a6cfeb21eb2f43af49f6'), + ('\xea4bd57c77345f1c9cf2e262fed3de4e9a43f0dd'), + ('\xea537ffd5e9fa09d1cc04a8d8e2cc6c492a26984'), + ('\xea55362ac83a31811ed5b31260af68c434eb9516'), + ('\xea5db0a20b2ad8d7fcffae527103b6593615c52f'), + ('\xea64afe3e3aec41c8a02dac813a1a08fbad9cf08'), + ('\xea6aaea554ba99438e36d0fc882caeb376b15537'), + ('\xea6ff545a246caa64074ba809bbc86fcb8589071'), + ('\xea72ca243fbde6b2fdc307ef56b17e87c516b9c9'), + ('\xea73e60ee7fe05b96f195e2c80df2ea9ae9f07c3'), + ('\xea76c34b6acfb0034b77017e83aa2c8ce234595a'), + ('\xea77dc025a0381736ac85685d2403b326588daf0'), + ('\xea79ccac7075ceb2c54a78e13f29a8211332ea52'), + ('\xea7b7f6ed6f23959bce424a78ddeb3ac1bf84368'), + ('\xea822f35234521cc44aa64dbf22cd0f4d3484f34'), + ('\xea85e1b0d40a11bb6fa8fd558a706164bca85f70'), + ('\xea8930537cede3b711aff64990ec933f11eea2bc'), + ('\xea8d356bb4b71c340261d64f72de824f9cfa9f2f'), + ('\xea8e27a259ddea908750e67735111dc08822b0dd'), + ('\xea9181b6f9e140040cd76e96bd78ec03a8e8c136'), + ('\xea98a5d179be9b22a225f29726796299bfc92825'), + ('\xea9cbd21cb22b733ace4d4c47d9a9fc3f813a7b1'), + ('\xea9f37612abcd842e0b4fd3f8ec59d3a7f0d3596'), + ('\xeaa0e8f8ead9961270bffc0be86071f87cc6929e'), + ('\xeaa6dace99e16bd79e84033fc8589056f47ad298'), + ('\xeaabd4352c05d1ef3679b21adb225aca96ccb9c8'), + ('\xeab19a958ec5beccc7a52f923a1c58102ac8d642'), + ('\xeab2bb1469b2357872fd432f69643782b5e360b9'), + ('\xeab36d3fe02b1f606f817fdbfaa622ca52ec3099'), + ('\xeab616f219a342e7ee8b9ab376069371eaa5af70'), + ('\xeab6bf91b79f09ad206adc6c6ebd4dc7acdcdd60'), + ('\xeab7c3e17f079d1f69bd5468ed114e43cfd5afff'), + ('\xeabfd79c211633c5f0e202de8b774a30c67daf2f'), + ('\xeac65910590cc08ad330a8a863005a0b0a897eea'), + ('\xeac6efe181b69f6bb574971da72399601bbad48e'), + ('\xeacab58d6dd4ad1bf636858aa6da3ece1bd021ce'), + ('\xeacc28c7b000cc1f761134892a1a5dfaf21da09a'), + ('\xeacf128596b89e91fc6c0637cb5a262c9bd042b6'), + ('\xeae383047c81e8ec80f60a736d3066883fceff7d'), + ('\xeae9b3007b07029703491f43786b66f69dce8a4a'), + ('\xeaeb0315ca1f512b9cd4e52b477a05d97a214c80'), + ('\xeaec28150b3ddc09c424f76dcddd1900fc879635'), + ('\xeaf59441f5206d7c1b81c27fb7a2255eae7381fc'), + ('\xeaf7df7a07d669ea97474e2b0851a363b405e9c1'), + ('\xeaf8f1600b9c5016caaf48d50942bb60f0d4cde5'), + ('\xeafd226b890316f972149c717c48926c3d32d146'), + ('\xeafd60b44337cbf232dfce1f8d1e8a57b312cf36'), + ('\xeaff25a1c102caf1566adcafde9d10b12360fb77'), + ('\xeb06cd08c23387ac887966438a856d5a966e87e6'), + ('\xeb09d1558aa304c2979cf0ce3d3d6803d31c2557'), + ('\xeb11b3efaac01460d8e83ce1e430443784a09f99'), + ('\xeb1823a865fd7836f2da8f82102c7c2260db7d16'), + ('\xeb1b1ec727bb9366506c3b1d4186580c0da2d4ff'), + ('\xeb1ef06ed60d6d87dd5da41eb4e9949eb863da8c'), + ('\xeb1f7e5a4358f7d875c0b4b78bb955d8ad81707f'), + ('\xeb2037492401ab4b8d4695e6b32252a73f2906d1'), + ('\xeb29a59443d96b7ca8da0a63abb9c29c9c1f4005'), + ('\xeb31ff1e7e13a5a176d088ae2a10ce54bdab57e0'), + ('\xeb3c7ac0a61cb4d1ebac27f7bc521d0949affe40'), + ('\xeb3e156b701b1086e8ecd7771673418e2d97ee11'), + ('\xeb423271fc2ba6eed98b7026803cd3cf56934808'), + ('\xeb4965b84055c8e8cf913b9cad0a9a6269895dcd'), + ('\xeb4c214bee025927ca5a35ed8bd81b2b0f557ba2'), + ('\xeb4fc8ba67ed6568116d86e4733821bc7f9dfe13'), + ('\xeb522f2e7bf700dc732c9776933a07b0c329c77f'), + ('\xeb576704d5a5746c39e985c033c31fcee4e518e7'), + ('\xeb5789ae52e8048da097f260923b49bb179a0272'), + ('\xeb58de0702dc1b2520ece1e8cfdeab857e4fc8ae'), + ('\xeb5964dde8fc21d9b66a21673067b37f85ffe76d'), + ('\xeb5a0e6eaea2258c8d8fd81baf122457f6cc4a08'), + ('\xeb606efea9ffeda4cb139d4a1b6bbdfc3677bb9e'), + ('\xeb61f14b6b07a0e278374a4b551ccca8bd7a21fc'), + ('\xeb647e7e354acb64c5706f700bb289d4da204e91'), + ('\xeb665a6ce8e0891f355be3494eb976f596ef7ba4'), + ('\xeb675192462fdc9ed7518d11f788b7bd20a8091f'), + ('\xeb6773612ed4d265bc368f466f74af7527cbc53e'), + ('\xeb6efb73c856ca3a99384a5cec833ad313e8e8d3'), + ('\xeb74694ce7aecafde8c800c20391c6205a4acd71'), + ('\xeb759389e6afdfb1fc5bdc2471875a7701f8ee2d'), + ('\xeb76f146ae850ae3736056d7e4cfad4359328556'), + ('\xeb7e03c5c89738943b1988b3c30aa0d7e6a832dd'), + ('\xeb7e48c16890fc241b987432aec2621fd50d4679'), + ('\xeb7f72470a8a4739e3e03586f70ca04970e56174'), + ('\xeb7fadc2a3af9b9199b708f986af6911287d23de'), + ('\xeb812b7538bb0a34b601a0f9aaf9cc2a88821290'), + ('\xeb8b36a0a347bee20a9a3a050f03ab5443c1701c'), + ('\xeb8c1cb20e652ba825f188aca4dcb36410adf43d'), + ('\xeb8d20212ce1a2aa37200541432c11b60830aaf4'), + ('\xeb95b917f3fbe1bb7ec42c1b84826f9cc9f1715b'), + ('\xeb96ccb8c89c2f72a7a1b6a8a24f74c50ac363f9'), + ('\xeb976097e484105caa09517aebb8dbf8c2c52241'), + ('\xeb9f22ef7d93ee82e1c68c86ca72821cb27108bb'), + ('\xeb9fab5376f8d2b4df9088723472e31e7038a006'), + ('\xeba0a8448b13565acb826dccbf6b7d97f6888a3a'), + ('\xeba306253b862d44c5eda9e92a597ce43bd0ed64'), + ('\xeba79f37fb54eaa801543cbb2955063e0385b9ee'), + ('\xebaa7857837c84592e827dc0e5f00bc97de96822'), + ('\xebb1eaa1beb937299fc3ef054744183d0bf1354b'), + ('\xebb5ee86b29d489e79642baea85c25a32174d937'), + ('\xebcd1f76cb16db7d362e891a4b319135f3058afd'), + ('\xebd2434077998c9497378f989f58a68f9655899a'), + ('\xebd4eb1e3f9dd77621461e3704544c19be100c5f'), + ('\xebd8a0d0001beceeb6684f1b2e7f99010803890a'), + ('\xebda38c8e03d44b03c8cb36b686f2c777ce44de7'), + ('\xebda907ce9cb4b816a4368093c0c8647b4393adb'), + ('\xebdbbe71ecc4439602cdf763a24006f44b1139a2'), + ('\xebdf040914f0490b9efb238f673a8952a429b3cd'), + ('\xebdf4799181a3c2d999a2557efd07bd589ef63cb'), + ('\xebeae129762d97b6a380c3b6feafa72eadbaea1c'), + ('\xebee9d83b60e3ffe28cbb611bd7c8f57c7c28ae7'), + ('\xebefbeb81f63da792a887c4dceeb9858a82887ac'), + ('\xebf0f0a6dab1350b3e339673577949ed98a34b0b'), + ('\xebf5a26b672a674e4484dfef8453316c0ac76dce'), + ('\xebf684866dfbf4c1324d83a5a7af42f382dd5636'), + ('\xebfd7fc2c94ace0de0dfe1de780aeded1e8d407f'), + ('\xec00191ccfd172a0ab85af064895352bf62127ff'), + ('\xec0cf209c43d2bc884c2924522bece713113f12f'), + ('\xec158278140df5afae4c817929bc8090f7c3c766'), + ('\xec1637c3be9b8c9b23d983d48a14218e3406b6d7'), + ('\xec17f4523f4b6b3b5f58aee8e53134e242fd6a50'), + ('\xec182bd710e1f2ecf5708a7842c70682212f1e35'), + ('\xec2265f7aa7bfea27ea95027ae53f83c33e0dd58'), + ('\xec2d30d5d9d5010262ee62580104d823d0bb3a3c'), + ('\xec2d428e6ab3d37317ea769016caf8b178d522ad'), + ('\xec33ee49015edc95fa3aa6fdbe47584b4e4805b1'), + ('\xec399c5d28a6f434692ec163a2b038f281084583'), + ('\xec3e17613b7ae41a4decbc20c3cfd7c9b09b5073'), + ('\xec42cb65e0962be932b91253040c04edb69ef56a'), + ('\xec462213dbafff18f27653e74c3ef97d7a227970'), + ('\xec491c6c3452f45ac542d9e9d818be470a6fda25'), + ('\xec4b094f2181b26b9631c24889249e718ed25202'), + ('\xec4bfc6d1902d70e553f3f9275efba1f53ef7bff'), + ('\xec508fc72b38ad6cebef21a055cd98789f0b9eaa'), + ('\xec576921ef0b35aaf238bd32b21283dd5f9272b9'), + ('\xec5ca24632b3e24257b21fd4a1caf06f68e3aa8a'), + ('\xec5d317bad728b54e23c9f0efda7d6bf1898f51a'), + ('\xec6528bbc000c175ab98ef157ae2b08aac66dd6b'), + ('\xec6afcedf46cfc8bc57b4b9b37131d8faa91c5ea'), + ('\xec6c3f91df95a00c53264e8ff5a3bde6d21a1a59'), + ('\xec6d62d34612ab01a900cb59f9175c3a4163c3b7'), + ('\xec70d4ce90a24cb7ca035059fe137243a627e3fd'), + ('\xec72ffbb6a320c91ffe5db751227d149e3642cd3'), + ('\xec77d49b1acdccf616dfcc75c9e93226805ccd8d'), + ('\xec7d5e2314b00f919bf01cdfab7ecd4566e41706'), + ('\xec80dc8b420a924e6eaaba2bd6751c9c90929c08'), + ('\xec812eef5abc6758c5270c076f4987e514248f58'), + ('\xec9009b31397a3741368de003cad1f47ef18b691'), + ('\xec95c65ffface7c8c68ac3398e05d3209b6e8f54'), + ('\xec964c732c6e6a1d7d4c6e68b8b34fd23fd5232d'), + ('\xec9a4e98e31bc9d3efef25a5fe24cf268ef6a481'), + ('\xec9cf080fbe4a2bc812754accef7358749fc8760'), + ('\xeca2da21060635fe94ffb26bbf625eab933fbe63'), + ('\xecad730c873e5ff22be0399d530c2eb1c8611634'), + ('\xecb29343f9fc426c8ff109bf6c40e3a672d7db2a'), + ('\xecb49bb7e3feb9cd05569ef8fefa376803e5ec57'), + ('\xecc57fbd86d95553f411e8e6c19688f7d9bde7f1'), + ('\xeccae3d4c361bbf2700956e2645c9a09263c0b75'), + ('\xeccd6f7c6a1cda5bd49db67c119b82129f2f28d8'), + ('\xeccf30fe74292a855b240c170049ba9ff22224c7'), + ('\xecd1dd241d22d37d5a53a97c6d8751a53bce8186'), + ('\xecda8d31f803f0f2db7a237ebf6e04cda2f0d946'), + ('\xecdd19fc1cf6b25a0aae88604d6689c0ca0e51c7'), + ('\xecddfc66a1671c4334e2ed9d9034e27cc97ecaf0'), + ('\xece125d761da3163cbf0452408e27036a3a1824d'), + ('\xecea59b4fe4a8e232ee7bfdad71534bbb3ee3748'), + ('\xecef0005ed4552af26a04f65c0d5d6b89c84d0a0'), + ('\xecf3d50eb111d1b9e143357268fb08f454303e22'), + ('\xecfc88c59a056b77a9acf50f1d5b60a94abf56e7'), + ('\xed19e7d6779b47d8c63f6fa5a21954dcfb6cac00'), + ('\xed1a0a2a78402e90c70ff6849018c1edb808c017'), + ('\xed225c6a98cd3b994a8c4320b5eb55e928f77cb8'), + ('\xed2eb33c57d418f5e99f3809bd65d158be55f539'), + ('\xed34cbccd9aeed7c458c36144f68b1a16ee07686'), + ('\xed35edf989fc6a67eb58a967a792bbe5e63af13a'), + ('\xed3e59dd16b9cdb84e00fa787756508f7e847914'), + ('\xed3e9aecf36bfea445124f216d75c8b5ad262bb4'), + ('\xed3ea29846027206dbe89bbd274cd20f8e73c020'), + ('\xed3f7d591e55ed828b25741d00df5b65745a2ab8'), + ('\xed430756de222e6bf7983d4232ff7c37cc916653'), + ('\xed4ac59ffb8423dfae166c033b141fe1a8b67b8a'), + ('\xed51b3a847872236048d15d7368feaf8597ee33b'), + ('\xed597656ceaeb2c2c3fd6dd36da4d36e3a2b435b'), + ('\xed597660e4acc85986276b9cd075fb39d20d3e54'), + ('\xed5c6ec9dcccc6b184bc26966b5d4e4ee5f5862d'), + ('\xed60f968a385406634078de7cabfe1f1f4864123'), + ('\xed6431f0e04ff7b51bd80bc33b2296ad086f3bee'), + ('\xed668d4d489f066e40496d2b2390e6227e26a457'), + ('\xed6af245bb70ab962b587616db270779bac6acf6'), + ('\xed716b0385b0187943768eb108dca85d507e5de7'), + ('\xed73071830e9a30eecf0bc1dfe3fbbb336d1a0ba'), + ('\xed7571672da5ce01d8cc8a034ac1ad308f39877f'), + ('\xed76988846a1c32bd7ccb0ed876fc143482a895f'), + ('\xed7f21ca7909d4cca335076a621d5fc06575a5b6'), + ('\xed7f5265407a1c12cdbd6f31b990bac0877a5a8d'), + ('\xed81c78772317dccead868d20e70e9b2df2ea696'), + ('\xed861282b1bbbf004c2a5c610e079bbe9bef5b2f'), + ('\xed881b70f55551e52851ae96fe21028444ba5ecd'), + ('\xed8b29e89a9a60f2c13fb00313789c67dee827d5'), + ('\xed8e0af0da344364ab8028f555dde42d2cc0009d'), + ('\xed949f102ee49bdda6b9ad07ddf74fc4fc80ef23'), + ('\xed9596ac11e35721a3c165cb56b656687d55d8cd'), + ('\xed961da410f6ddca9098cdb8f54d765aeb9c8d2c'), + ('\xed9a2a68eff5bce102279706e0bde8fde2708820'), + ('\xed9b1bb33a864bbb32b29daf0a66d900d19fe619'), + ('\xeda29c35b93c15a36e60f3ffdf8ead0e942342d5'), + ('\xeda77fa8c581ab78775138a2e7a8bc15cc2f20bf'), + ('\xeda825c6f1bdef44954ab21053e77593e6e147cf'), + ('\xedaa5c50a87d5f92b31f215e0d31b11ac8e3c015'), + ('\xedb3864d44d9055fc77c788431e384cb2aee45df'), + ('\xedb49490dd803ac919a6462c027ca19b84c1d7f7'), + ('\xedbbff759acd7a5702b92c920ffd6648b61d9928'), + ('\xedc01a3028d086d8b830e31291cbd38697c771ce'), + ('\xedc01fc7f72127681745c4378d712b7ea22148cf'), + ('\xedc36bf772a89840fa30f62f4700249e07bf130d'), + ('\xedc76983224dae3a6ca89d41024a4eebb8652e79'), + ('\xedc92c1bbeab8f73b308735e3835803d2bb02a62'), + ('\xedcf944493868a6a149b32ea850dcda00489ec7c'), + ('\xedd76c5270488be3a2c517f2016497eb710cb106'), + ('\xedd7d6f28f10c129449492364da2a6f8da4cf44e'), + ('\xedd922d252a6c2216d86abb0bbb09b7ae310f30b'), + ('\xede49829b2276b863aafc5e801a8efd7d57695dd'), + ('\xede9603e0ed6db639396c3c710035d52a17e5cb6'), + ('\xedec4533293167acd7cff0dc56c653398b2895c4'), + ('\xedef70f0a56cf03ea70ccf0e4fe23e4f0e4ee130'), + ('\xedf0e54ec1b1a2ba5ef3231286f954fc800dc9d3'), + ('\xedfbb3592c1172706112202b77c8ea0e971ba118'), + ('\xedfddb006b24d68218150f5e4095ca67def722f2'), + ('\xedfed59251b6422a65ce5e58506ae08c420101b8'), + ('\xedff1905b95db737b4a5684cccbb4b79181dcebc'), + ('\xee023601adc1261c367564c2efdc8d1527601953'), + ('\xee0a9c137916ef5f9de3e28f10b9cfb1cd821b9a'), + ('\xee0c5ea0362bcab11bf18bfb5d96c9cea4583e5e'), + ('\xee1a16aebf79d864d336a0e7f2b4b2162514fbd9'), + ('\xee1d71eeb2cb6bb88ec96c9835252738cab0ecdd'), + ('\xee1d87528c20ce5fb2cccd24a380374890cb77c2'), + ('\xee1ed51675842299212e54d4fb6855bc4e68d0dc'), + ('\xee29f729f6c2cf46eb56188209b0092d9c2e2e90'), + ('\xee3057b38b8750420371c9f0e2b89f4820ef0f88'), + ('\xee35e839b6426fb96968653f416daef7b089841f'), + ('\xee362890c9ad25390fe4a6f506392107bc74c542'), + ('\xee36fffddfd8ad4565de201715087e38a9c7e51f'), + ('\xee38cfce860a10dabc533eb2d7e0ec949a801a69'), + ('\xee3d02cd59ceb001b9c2a77056c2bd4896dcd1e5'), + ('\xee3ef84306a256ecd469a493edec1318b86346d8'), + ('\xee3fd34b1a77f5c1fd450534a594318ed7f898a6'), + ('\xee4215d778c706790208f4e9620b83d2839ab65a'), + ('\xee44548ffb2863963da404b600149d2e3881e9a4'), + ('\xee4475b8a811e677106ef6b0c449eca5c40fae72'), + ('\xee450f29a12724a538d9479427dd448e4827eb6e'), + ('\xee5233f7a9fbf059f47be862ae5f295014e49308'), + ('\xee5300910ea1b7d83882a952a0c2408fca3130b3'), + ('\xee55ec85c6c74bbb6681867e6ead5dccf1073acc'), + ('\xee579b8fd2e2c8db4d89b1817677824c359c4a3a'), + ('\xee5b86f74abd96fae240e7b0ddc37237f72fa519'), + ('\xee5d27d43acc88777f25a84da4778228e20e33b4'), + ('\xee60434b70a6ddaaf76b8f3ddcf193203a4efe00'), + ('\xee6054e7da21d2102fc933808a628a9dfe201234'), + ('\xee6388545c843f02a25070238cf3d2377058d88b'), + ('\xee6d0c884c09fbbb938d147fef68052d060415d1'), + ('\xee6e0dfbdab8a94ef474f6b46c68fcdb79380fe2'), + ('\xee7160ed84d6f168bed2c76e1a5ee89c0b2f8d9f'), + ('\xee77c7ff65a0c5753b2cba577563daaa2bc05bb2'), + ('\xee795bb84d3a51d37a273bb84d845518b6dc5cf5'), + ('\xee799d0addbc8b9690546b27f567ab928bb59431'), + ('\xee7b8a9048bf40879ecc41e53abd154efbbf2e33'), + ('\xee7e4a1e7e7397b1be9ed78d21f2f4fa648fd227'), + ('\xee7ee86f49e006467b228f8c61197f3611441379'), + ('\xee87fb77d14ab10891415cfb2b75241bc2639101'), + ('\xee8ccfb48fddbb4d0d213f2a123bf63db63d699a'), + ('\xee8e5109a72d055d66656aad8061644caf2bcc83'), + ('\xee8f3ed2b22d36efdf4f1ba58c51b5eb0c8d107d'), + ('\xee94cf6a094e60e41e79dbb06fda79601207dfea'), + ('\xee979fbe510c2f927d5c487ea38ae7e2a202bfb6'), + ('\xee9ac15d04092971c23f17797fe006e59e555eb8'), + ('\xee9d9af3b327473342a7e0405b0cfb0b7033e681'), + ('\xeea1f0d6da5931bdb917f547828a48db06463764'), + ('\xeea2e58b4ff94fe3a955a352b75eba21475bbcd2'), + ('\xeea5968b7c60307ab1a74e37ab48380288148dec'), + ('\xeea88a431b161cb52185642a3ac10f44fe8aff32'), + ('\xeeaab0830386e33e22d772af4349c291d4ace1e8'), + ('\xeeb302b0dea91fc8286552ae6e169e70e400d362'), + ('\xeec4c030469ad03e5802e558abe9493ab3caab40'), + ('\xeed3f366853cc854e1fe552a75fea610bbe484c6'), + ('\xeed48cc585b561857d640058aba3133afefb1ae5'), + ('\xeed543ce19a41f6e7a045219d11a3b780abed26d'), + ('\xeed9155182c7252d1d61b285a7e67323ebd94278'), + ('\xeedd621d727d17caf6121e9c993512be3436e298'), + ('\xeee234ffd3514e6965df9497a6e8d32398a45627'), + ('\xeee38873f2639b00ce82b0e02eae5be36f048484'), + ('\xeee44e8111c00e3ce62dc67b3fe61b74b42327c3'), + ('\xeee712dc2bebf836bc75705ccc5c78eae06564de'), + ('\xeeea2316e0a65efb5a324c8b31b953a2d766cff4'), + ('\xeeee2051a3c0bd4eb07e49de3275175b376ad091'), + ('\xeef09783a90c8538d06b0ae8b2285496afd1e807'), + ('\xeef16192e49c1ae1b2ab1025964fc2bc9e83d47f'), + ('\xeef5f45bc0c016ffd21cf72bd867f1ec46dff3f5'), + ('\xef02efaa6181b444eb01191ded09c8864fb79b7b'), + ('\xef09c444aa1a6089a5597a71834d50c558315dae'), + ('\xef0d0e32ed5a512633b91cc659a51e931d9480ce'), + ('\xef0fd70fe3725ef07766590722442cd72fd51957'), + ('\xef10b0fa1a57f45376ac5fa34678b16e0945c23c'), + ('\xef11d8b78ce2c64b13b5399808a84c53161405ec'), + ('\xef1215aa62d1c3de61c357b78b1d8899ec4dd934'), + ('\xef127948017689cbf8364b519b857f3e52c474b2'), + ('\xef17ad112445e5d18fbfbbe601aff8b1a43765be'), + ('\xef180b59e03b955d71a111071ebaaa0e2ab31cf7'), + ('\xef1c0bf802e9cc1c41ffe45b4527bb8417a0f125'), + ('\xef239b64dfcadafb169afdd79236be883cd7f0f4'), + ('\xef23b49c4704b70c803fa5c411a80003d46a7b53'), + ('\xef2bf088230413c1ad16e6eb2d42245ae07daeb5'), + ('\xef304b1b10f8497c43f652ee01457acd71aa193b'), + ('\xef32c6c3fb9c7d34e84906c20980d91d7886f53d'), + ('\xef39eaa4571e5e8b20fc888843b6ca01637b1cc8'), + ('\xef3a3753fad851e30c01993eb29c2dc3e5b9959e'), + ('\xef41af437ec7df60b7cb0ae69b0f77d00ce0d6bb'), + ('\xef421595ab987138328ed5d403269021ab484fda'), + ('\xef477de8d817a801af88e9b94c5e1413489efeb4'), + ('\xef48e69ce851764d106a500a7692e992ecc26942'), + ('\xef516ca8a23f8fe758b05adb6519b22a7fe31e58'), + ('\xef5395a2c513b970a41498230602ef8d9c09361b'), + ('\xef6878863f3b0b136e1a7b32a11a5509eede5c5d'), + ('\xef6e4fddb9081df72b2954797b97baa0ca5da48b'), + ('\xef76279ec19a20ac574049c03e5d8d5e9e89b9ea'), + ('\xef7aaa505b2462c57d2f41f87abfab550c5d7d54'), + ('\xef7f803261d47e9a7353015fd050dabc40a46607'), + ('\xef8dd7a9e26d0b6b43da03ac897c2b4d305b8ec0'), + ('\xef8ee88d99af329f1401649164f073f3d6747b93'), + ('\xef8ffccababbe36c7f2803e2da10536f8887c0cc'), + ('\xef95cd0f649dede5dcd6b276fc8fbf4b43a683f5'), + ('\xef968f1dd4d582c7930e06b328213327a8c30d41'), + ('\xef99891e572665e1af8686390d95a4a145344dca'), + ('\xef9d5d85a731cafc12b2700aaa0509a671d38413'), + ('\xefa34591ed8dad4059076e885153b932f5b92da5'), + ('\xefacf7a5a6da9a695708d9baa9f173684621f4a5'), + ('\xefbb4da45c19e1bad20b0f200f905b88877e467c'), + ('\xefbbce80a473d18bcfc5cad6e1e6d29fbf95c68d'), + ('\xefc6b1d70c2d3045183c63337c372bae909fa2ad'), + ('\xefc807ca5fc790c2abf0fd66440cc0a5da14bb89'), + ('\xefc842781fb7e8060da82d4a46b658be8827b27b'), + ('\xefcdac0e1a84caac0404dce88e21c18e02009293'), + ('\xefd9738ddf293fd43f67d10098a035e1c2645a73'), + ('\xefdda6aee30e783e0db286935815f00314bf7c35'), + ('\xefe1a528e9d5785bc20a7484fbc545ee282486ec'), + ('\xefe56cbbc905373175b9e32d9ad9d5ae250a813d'), + ('\xefeea25ce444b2915aaec4da8412a891348746a4'), + ('\xeff17e15a2399a2462359f973669f35d967dbddf'), + ('\xeff1942cee68a6a35cfc1360fc7a48045ab733e4'), + ('\xeff23a3cb13e2515c4f20b9c1d2063770a1fbb8e'), + ('\xeff3616d647ac0fae414df60ff13d8f1633231a9'), + ('\xeff49543a348eb31cd69bb7611697c38f9b530b2'), + ('\xeffb9ac60f02f445fba33772a323c674f05fa4c0'), + ('\xf00ed01bbbf99d296299ebc5c38bb99bf4c9e96a'), + ('\xf01a9518ca57ebb1385b1145f77080075dce7c57'), + ('\xf01aff47f12113aafa8192647bd4653456838943'), + ('\xf01c925ee7afde345a97514fad6119cee3b10859'), + ('\xf01cf60399ef7e8fd2ffda54052bff93fbb5f75e'), + ('\xf02b7094493afea3365916d82313582dcb962c40'), + ('\xf0329d694949c6888b00df0c695531e3c37ea409'), + ('\xf03984e8a027bc9df3ae7986f9558495c2e4e15f'), + ('\xf03caf4ede463316428168b9bcee0fb541bbaec6'), + ('\xf03f49fa40fb11499106b5a2f12b9102f1554d1c'), + ('\xf0451f5d8c6427a75a3079970a2a056a2a476ee5'), + ('\xf0452e724ff07163294048b9b97024d2056071df'), + ('\xf0469ed381aa85bdbb9f925800ab4de55160436e'), + ('\xf04a32980e7de1c230577157e74f1a49c9d6478b'), + ('\xf04f10b37ee09ebf3affbc8ebd8f7b4b40bb1c8d'), + ('\xf04fa1bd021ab7e356636e6d6f9cf7ba0f45c723'), + ('\xf0584981b6087c11e36ed7c6476c1ea6cd20563f'), + ('\xf059c85fb9b0f7a4dcb03c8862c1d1e07cac4462'), + ('\xf05cdab7c8ec27bac2bd89e967060d6b93135a3a'), + ('\xf05e0cd23036ef702e7416fe71730a24c9011275'), + ('\xf0626636bd0aa7e808386b2779a69193127e71f5'), + ('\xf064e7899f63eb4dc3b5eab2bc5970ba12151ef4'), + ('\xf06b30db32c7ccbf12ef453efd46990c92e8f5eb'), + ('\xf06f2f329894da35f3e14ddb143bcc5f748fb07b'), + ('\xf0723824a8d8a0c8c79d52d409c78ed1ef9f3500'), + ('\xf07552ac413a9fb58146bccf7d933c497098cca9'), + ('\xf07afe84288c521005b6031c597c12408620d6ea'), + ('\xf080714c882e91e77d3c29587f7926ae1c157335'), + ('\xf08c0726c06c8460ece2a322a983c432f48e2190'), + ('\xf090d27a44eaf809e4ee6fcc714f865c335d4b77'), + ('\xf090d9f2f4202cbe48c7bacfc9bc2e0f76dff680'), + ('\xf0974ae71eb7418692570fddc7bb8ac6c77e6126'), + ('\xf0a24d4eb855d02adbc2a22b31e92795116066f4'), + ('\xf0a944e0b89642ae282047846e55393007b04686'), + ('\xf0a9c97e227aa943415f3eeda15a75edd91ee107'), + ('\xf0bafcd3d058ca783f0d1e65673a995999735f0e'), + ('\xf0bff1a234e27c6846542ff52497ca22ada7a5fd'), + ('\xf0c2c5e359f587fa3d98b567a156ea96ac2ac640'), + ('\xf0c2f166409affdd8c3aa154392782d98485058a'), + ('\xf0c342298e36066865ae5e5db4c3cbf48d7a8c58'), + ('\xf0ca454d39ca95b0dbc2c842091509eaa0212f08'), + ('\xf0d15e263503b8b0af939ce853461340ab70d8d5'), + ('\xf0d3988fdc3f18243b4829d3a491ae44dfd07c88'), + ('\xf0d3a81b6f466014bb432db19b850342180d0078'), + ('\xf0d497579e7eed32447ce8c499f1f125b34c61a0'), + ('\xf0e037f13f7fdbbd715c024974fd6df505b1a595'), + ('\xf0e3e89c2204d53d27e8ee3192975c8e75a3e3cc'), + ('\xf0ef076a599976eda2e79e742bc8660e4994afd4'), + ('\xf0f357ab5dcfb353e150304a627f31d613f1f273'), + ('\xf0f36451534cf8131f7250100c97b7bb4f2875f2'), + ('\xf0f3c93daf8c59251494fc37abc4c6d1ed3ee621'), + ('\xf0ff171c606833b126676891f3e8cdfb4a18377a'), + ('\xf10177df4fb48f0d39143098eb89cfe78ca58dd0'), + ('\xf101f8ce1b1c702b1a9bb0d928150043433f2066'), + ('\xf106bc91ce670bd4bd13593439e8d649059dbff0'), + ('\xf1081ba2785fbfcaf7ec226ea9b30482459ef688'), + ('\xf10d923225ae14badc507b52c066bd3a0aef18c0'), + ('\xf10df052dfecd8f0d28c50e9b7412792712242e9'), + ('\xf112976a7b2b955b40ff0ea22ba8dd11d317f645'), + ('\xf113af8f876c2138378db7c3fab5782daf4a537d'), + ('\xf119d292e0001916367f7b69e5a6ad4ae3cd98c6'), + ('\xf1227d8ff1787bcf3690b85380b5b4481603b9bb'), + ('\xf123aecd61d44ee3981805b71781f2c73e7b3a92'), + ('\xf1296f23871c1870bb151776d1a83d14bf576777'), + ('\xf130cd6bb6876a46256ee01ab2b820e5e09e7176'), + ('\xf1360edfcd2868556c29d2f8343a830789de486c'), + ('\xf139f6522760bf4493cc000104f9378507c8aa20'), + ('\xf13c6de58813b83b5cbd96799db6b2b309de98f0'), + ('\xf142709144fd608c0fdad15628ec4dcfb0b33d8f'), + ('\xf1471b5eac612c48212a74e7b5ae345ae73c28c6'), + ('\xf1484d02e554721c23b6e5a1c20d89c7fcb93a50'), + ('\xf14d1b3e196385581e8e9a3239fd7baff53de4fe'), + ('\xf1514cafa84a7a4f88903ce9f7ff3de35d922dca'), + ('\xf15958ec389dce5e47409c71a9a334fc03a03230'), + ('\xf159aabf820d99a5ac0528b62bacc05460ecf49f'), + ('\xf15c5a51458fb478c68884afaf23b6e0398c5749'), + ('\xf15cbec9d9f84eaf961be941d82643f4b8ba2f21'), + ('\xf16c5d3cfe691fd9a4a485d159ab2e759edddffb'), + ('\xf16de12d9917e9d833df28c6e86cb6b120b8e10e'), + ('\xf172c2a42f1e852ae652122f9f791cc6cba34d0c'), + ('\xf17376bbcc09b5c0f5b77ed7b82dc92e66296f00'), + ('\xf1757c00010f50cf0cee10fa178274cf637af0f4'), + ('\xf17cdab5d5f75e841338ea516036c7ddcf6d40f1'), + ('\xf181fbbb8f973c87d03c5a92c6fff83dd14db4e8'), + ('\xf185dd3d61e0b6919a403230a82a460330bf57d4'), + ('\xf18a65ac0e20ccf4c38e97ab14eea8ea5a73956d'), + ('\xf18a9a592933e1ee19c754689470a51bff5e7ccd'), + ('\xf196c7f8d9064c1ec82c8c569d609dda106ee8f7'), + ('\xf19a26f9842530f488923fd8c956968c2b14fd2a'), + ('\xf19be6aa15144cb6129efa44607b5ebac326cecd'), + ('\xf1a50c0e9cc10af193881e42578609980707528c'), + ('\xf1a6f7da5443cc802552cf9543c7dd53f470b8f2'), + ('\xf1aad35d1306288b6f32582efbb5a85322f2dd82'), + ('\xf1ac4c191753275ab4b979a40a29eda436ce38ff'), + ('\xf1af96b6fabf05eb1f15099bcf88eb374f36a0d5'), + ('\xf1bac57904280714a9924605c11be9b40da744e0'), + ('\xf1c175ac4457daafd385ba46be67b2f22941f922'), + ('\xf1c1cb2b6bac249b128d373a6d0fbd4a86558d12'), + ('\xf1c92efcc812f74b702cc4a285d3f0d246af0d8f'), + ('\xf1c9d97ad1b24a1b23175da35883b0f83dbac402'), + ('\xf1d72a64b69d45051a00733212687c582a8e529f'), + ('\xf1dca50597b3a0ad855c08dfb377d485d960c48c'), + ('\xf1dfdbf94f325e0b6d49cde3206e5995c41e3f5f'), + ('\xf1e0e12640768ddff4f42c6de194ec2a58076ed3'), + ('\xf1e51c3547bc71bbc58c486716ac249e2318564a'), + ('\xf1e591fd39520e0a4f08097b465003cb16f3a1aa'), + ('\xf1e9b55d45a9618c4c13544b3f13cf4e86b73d98'), + ('\xf1ea9aee49438cddf8b74d0b0f6376d86cacd7c0'), + ('\xf1eabd18cae0f418b6b2fda467f45d39b785dd3f'), + ('\xf1f0f2ae7e3a7002d1ce2a8528ae684ae795e3f3'), + ('\xf1f6bcd06d8096113aaeeb87db1ec9492445daa9'), + ('\xf1f87d5eff9cf60f589ca798459f3735fb815de9'), + ('\xf1fbbaafa3ab8b137797bcd0aa7c7ff8cf53c1a5'), + ('\xf20086ca635ad64a7006974c824c1d0195244f13'), + ('\xf20a935b4a8bfd4e8b4a903ac7d73e8767f49162'), + ('\xf20acb7e400e748fae879fe49be9aa0a16a8d9a3'), + ('\xf20bd632c9a1709534ec1fa2d8dd4645a57727ff'), + ('\xf20c3799378a516bc83745a42719e41933e8a352'), + ('\xf20e1ccf212b49cbac8480fa626d02424b6b565e'), + ('\xf21484cccfc519cbdad7fe6a8fcb8a9fa6ca1896'), + ('\xf21a658e7d95427850687facb6be72b595fa1dfd'), + ('\xf227a3880ef61a0cac273e6a48a2d7ba8114b335'), + ('\xf229337656c037d97663680c52ffdec390b8df33'), + ('\xf22e9ae80acb77d6b4a391b5b7d3f75c7e6a96e9'), + ('\xf233c4ddf2aa9993a2f0d344ef4142e32dc63244'), + ('\xf23872266d398e450b80444761ea354fb23c2b1f'), + ('\xf239a0df4f6432c828e37ea25c345861cb3b5166'), + ('\xf23e54680b733bda6c050da350d99bdea7fb9933'), + ('\xf2423d2c82cbaf886d9c76da0abf7ef3bfcabd17'), + ('\xf24453b3647444c43f5a934308d26052655f10ee'), + ('\xf24519c3197a5e4ad85d0016a418e001bc5e84bc'), + ('\xf249270dd2bbb96869c60af049985e49b0037f3e'), + ('\xf24a49df11f59b2699858a7ec57cea3f90784fba'), + ('\xf24e5cd2af27c6d68171ad5749fba3acb7db4dfb'), + ('\xf254e32128a01d2680306eb1f5dca8657bfcaee8'), + ('\xf2598ecbed356b8e095c9939f2bb05c62a2acdcb'), + ('\xf261edc26c14c32c1bf52113e9cd176b06be0048'), + ('\xf265bca6ade72d5886e31c320630ac3f2fb8087a'), + ('\xf269fa04209e08a30a8793326f1c0dfd6ca30997'), + ('\xf2734c724639f85c11ffd05dbd31ac7890a28b8c'), + ('\xf2744efbe1c58b8b79fbba55f7341abd9b066385'), + ('\xf275e40aa39c48aaa87c1d55da4fc75450f123dc'), + ('\xf276d2d6a260d35926fae16500c37966b39c562b'), + ('\xf27d3ec937da7dab1e0ca38400c6bb705fa20b40'), + ('\xf288702d2fa16d3cdf0035b15a9fcbc552cd88e7'), + ('\xf292c992333ac34af0cf9de2d8b06f569771a88f'), + ('\xf292e934f9a4cdcf7ac59f523bd9c19e30167cb4'), + ('\xf2973e3ae10bb5471042b65b4e743f7ea140acd0'), + ('\xf2998348ad76bdf88f68847bdd6767ee7a3568c0'), + ('\xf2a11938fd3d3bfcd4e0359c8075bb48116a98b4'), + ('\xf2b709efd52a8c211978f0842584078a1ab048f3'), + ('\xf2b72fee3f2e8efc81eb18dcaf08a87fdc9b19af'), + ('\xf2ba728effad7b63cf2f9942aa2858e4247b85d6'), + ('\xf2bb36bed3595e2fd52430f3727f515094750ab8'), + ('\xf2c0aac0529e9fe3c2db01703ba166f71d3d3720'), + ('\xf2c698533b2e908e4abf92032d5496859e9074d4'), + ('\xf2c93522cb1299afe8a928b8627d5f330093c7bb'), + ('\xf2ca619e0570790b345af0957f9c0ef34ed894d3'), + ('\xf2ca95e7166a4306ac434479a85c2efacf44c7d5'), + ('\xf2d20283db2eb8598262cfff05704b89b63ad238'), + ('\xf2d3a430f54eefb57a4ceb0cd79e8d140ee7282a'), + ('\xf2e31f73dfe720da4c1d7463004332980bafbcc1'), + ('\xf2f4c06127aad144dec51381919332c4d264082d'), + ('\xf2f7cc1b28df3566748022306406610782c3799f'), + ('\xf2faebf5ab48d3c49de61a188d04e928c2d8715a'), + ('\xf2fd223036d252356bcb5a89a4805ef36150e8c5'), + ('\xf2fd2ab958bff2bb354838ebd4928426987701d3'), + ('\xf302131657bf09209612d4cc94963c8c88ff2875'), + ('\xf306f6fd29ec3165f75d9a53dd12700c5bf4a1a7'), + ('\xf311b8983f3bc7e3dbf773f111374eca029195c4'), + ('\xf317069266d943cc2dbb9f1de2ef0d0e43bc3d57'), + ('\xf318c9411737bdad0286766c33c8ac2c9c7ac29c'), + ('\xf323abe28cce24a9183092345e1f2735498bfb9e'), + ('\xf32d1538259c9391ab9a70039dc61f8a6f6601d3'), + ('\xf32d6d899c1d9143154ec04a5823a66c5ec4f622'), + ('\xf33099bedf94c39a15b737cc4a853995329bc4d0'), + ('\xf331738de6a11feae7eb9c5b027e25ab3b3be3ce'), + ('\xf333dabdb320f38debd9c2ccf7e56a62ef105934'), + ('\xf3393c67b30bab798ec72ac97f2075f2f89ff9c8'), + ('\xf33a8ce6ebb0982ce9443ce60af8452c7699d8fc'), + ('\xf33c9a73cf55a9ccc65bf602a6f630d27c2799be'), + ('\xf33e59639ee78fa46610d81518e97b60e948ed62'), + ('\xf345250b7c7799ac6d193afa5a402484e9531db7'), + ('\xf345285beaf7585fb28a6f6cccfd666f2dc8447d'), + ('\xf3466af58fee6964f697310f48299ea29d07a47d'), + ('\xf346e29b6e7a54a0eda470a1548bf99a0f8f5340'), + ('\xf34adb46dc0c7778c19ae8516f75122b2d31dfd6'), + ('\xf34be13dd1b4650bd8c7b714b7e992265aa71de0'), + ('\xf34c33c85dfc8e2cc8ae44083eefddac855a05c9'), + ('\xf34ed5cfcb6c4361913797142441493ef934df52'), + ('\xf34fe67e69f4d16ad496091bfd28a0d0acc81890'), + ('\xf3510f549c54c3154b7cb663071d04e9162a6ac9'), + ('\xf3532e933679e2f0f625b37184ade83ef7e2d320'), + ('\xf355e00f3ea706b26fac6f56f79f3eacb0771d7d'), + ('\xf35b8ed229216c100bfc670df7bd02ef42ef7441'), + ('\xf35ebb001eb819be39af109f1b10a16bcc333879'), + ('\xf36a657759c1d3309120aa5cb594f91d8b49a1ac'), + ('\xf36b4f06c2a30a64b0e332c2b54594b33fbbd15d'), + ('\xf36d8bdd1213fbf3ffc9573b16cf3431951f3bb0'), + ('\xf37f8d3b402cd8fb56bf739b9f15b793e1265292'), + ('\xf3848a1bc2235ed561f8123de6ae385c39388f50'), + ('\xf3885666b21cdf083e948fc9f025a8a8c8cdcec8'), + ('\xf388d081121c7d386357f047173181e7d0fdd312'), + ('\xf39448f47b258ef0eb39e896e7568290225fd355'), + ('\xf396417976655c8ff74c1c546591a498b453d9f9'), + ('\xf3967e988d94d9b38fa2d3d6fc89a78844e59917'), + ('\xf3983a1431facd7940c3e2b679e56d0aa5554685'), + ('\xf39ca3a2bce3f1f996be4a1b49733915872b1e38'), + ('\xf39d5a732ffc210c5a7ba30988cdf966d47832ca'), + ('\xf3a63ae880bd5cd8968b1896d3e3d141b625e651'), + ('\xf3a7be09ce14f03e486fbf1d8f82e2256afab272'), + ('\xf3a812df6b8b18e14ac4c9c697e5e74ad15eebbe'), + ('\xf3b1699ef33cf3b59481fc6a5037bb5634dea2df'), + ('\xf3bac09de8d53e26c07da23ca5e2588d03a33b86'), + ('\xf3c3f8db43fcf32f7c501b3393232bb454f5629b'), + ('\xf3c74c0c03798c0c38d9368dff6acc2c8b451731'), + ('\xf3ceaf71532e86fdc5c57a326fd302d1e7afe3ae'), + ('\xf3d0ac89bba9b0e619d405a3bb51e1711fb3e22b'), + ('\xf3d0efbee503e00da545f2395796e333bd11479c'), + ('\xf3d4d3c1590e358bb079f7e1202866ab7feb8cd9'), + ('\xf3d6cdcc537b43a576bfac8861761b6544d4e9d2'), + ('\xf3df82918f1c80b3fd95f492ed7f5b3afb395ac6'), + ('\xf3e6aca5e3efadbb45ce68616166fe3314c325ba'), + ('\xf3ee85c5e57c34692eb85845c1bd12fcd9db35ee'), + ('\xf3f53c9da38836024a850d494ffb30e5d5b57fac'), + ('\xf3f5545a3e509aa19b18f1098d9b9be8dc1e23df'), + ('\xf3fc3d8ba198b371396f84f12d70570338258fb3'), + ('\xf3fd3585d649c0130caad414b4b3bbe9de5a48c3'), + ('\xf4046efb12ac7435c45f482fe43b708247a2ebbb'), + ('\xf4098ca5979406b2d011c8cb8d4eac276fab47c6'), + ('\xf409b711f30bb55740ad5240a1552dcce9248530'), + ('\xf40ac2f4acbb1ecad8195d589335a7486b8da6cb'), + ('\xf40bca77ec24a63412a2926968b10ea240c9f550'), + ('\xf414a9492f0b8ab69186efd4372a86e6038c9f7f'), + ('\xf4186b4ec22e27ad7b661576579022876ea688a1'), + ('\xf41bd3c6836dddee6519aa01b8a0dde04ecc2b1a'), + ('\xf4299bcf66a4be47fae2652d9ba0cb5c95ba1340'), + ('\xf429fb215907cd7e08f8319bed7768517f91acbc'), + ('\xf433e429d866963a4f368291f4a7ec36fb9d4a41'), + ('\xf434a679dff6848dd49c69c23b22ba594e2d49ea'), + ('\xf4422d4fa6524bb2ec796fd1099666c11fcbf11b'), + ('\xf44fc74ab914efd572c2c906d76a0ca6c85d4900'), + ('\xf451f56a5a514108fdf96396cc08e59dd7c87bc8'), + ('\xf46b5f3bdfbf159126efc6194faba084b3f3cd40'), + ('\xf46c21d7891759b537b9e5f72bf30c4f606d4f1d'), + ('\xf46c73b61e29a2187f6433b0b4639e1c1050b0a0'), + ('\xf46f4e57885ccaf22cb8330c1dda451b12d251cd'), + ('\xf470eb24001cd2ed4b14311b4f3b595bc02e1742'), + ('\xf472f625da813256e7620b1856285330121cdde4'), + ('\xf479cc55baab05963a4e2c04422830e14f3cc655'), + ('\xf482802f53337dc09bb66611bac0eed7c485df59'), + ('\xf484c6d70fed2ee5e04de2fc8fb22b0b982e115a'), + ('\xf48515536e3f237517e087105cb3dbe1b576dff0'), + ('\xf486149ec9a450e8494322e4999c747680528ff7'), + ('\xf48785da60cbc7d1f4ab28a4bdafbf0e1bc97444'), + ('\xf499c8a8fc77a49f51eb20272db1a8fa9a3de4ab'), + ('\xf4a7ee25091504961947c2dd9cc5c98711fb7f01'), + ('\xf4adb5869b5f709657343493523ac3c9ff0db6fe'), + ('\xf4ae0fead3f9aa2ef6e4a57e42f9e3af96a42304'), + ('\xf4b14477f4ff70fc590a67a57f97bcd25c1bd3cf'), + ('\xf4b85c0cda2d6bb674b949db7a89df9314fcc092'), + ('\xf4be624b6cfc52837db81df4937eade0305cea63'), + ('\xf4c38ac915de8a08604f48b593248ac4d85f73a4'), + ('\xf4c7c45bbdffa933e2cfd8eb8a419eeb35bfef28'), + ('\xf4cb03e8622e82287c43e02b63c61fd17b7a9105'), + ('\xf4cf4645580d1113c6c60d95ccf605946637ca7b'), + ('\xf4d1e83fd9e327d5329191e81641900077048865'), + ('\xf4d2c38f2099a1493ea64871c07ef3f81ec7ce9e'), + ('\xf4d4a73e11e1174c339b180fec0fae3e63932020'), + ('\xf4d51118e17f026306475da03da2c1a024df853c'), + ('\xf4d8abdd9d1f0406aa878879a37d681060007e45'), + ('\xf4e164bbd326632964b6251b4f61a7d97586033f'), + ('\xf4e7c51307316a80f412fd3e876390ec398f96fd'), + ('\xf4ec3a17d6005b36f65dcb1fe4da9cc89f3ca1c8'), + ('\xf4f228129b5ec00364bb2b23aa6550fc70f9675f'), + ('\xf4f51307446873cfd9abaacebc1b60b5d1b59fcc'), + ('\xf4fb12900eaf978ab047863464c9f0f493654d06'), + ('\xf4fc7bac9404a6f63479720162dca92e21e0a8f9'), + ('\xf4fe5d85d9e9db5be658d7f1aa93db1d9be56515'), + ('\xf50138f8917f15941872a4e875da4df4b55e791d'), + ('\xf50fda733f7634cb4efac65c6096556237cdbd21'), + ('\xf51407f3ca59f3456ef20221eeb9216b01b5b035'), + ('\xf52132fd150e513f99b3a77c145e9713c7294137'), + ('\xf525d0b4c9f8fa36b1aef235c6e4a55b85de8faf'), + ('\xf52dcfd2fc166bb1dcbfd105ba1453947ab0b36d'), + ('\xf53070c9721153e8b9b60181fcf567c22e29a4c0'), + ('\xf5353539c79019a02d8c2c53062e973e255d9fcc'), + ('\xf53654d1706710cbb16beb327431cf1615714c15'), + ('\xf53683b3ef6fce6da3494653fff579c940ac8917'), + ('\xf53a0d5b2c9618faae45d3f4078517b4efc0aaf5'), + ('\xf550b6340dacab7021ad24d3cd3071b9e67e8286'), + ('\xf551735e9be42fa1fb6cee6dc1bd097b2edebc4e'), + ('\xf551c20a7501f501f82832c7ed0f9b338d3826f8'), + ('\xf553598dd0ec4b4f4b9bb21b741f249d59983b38'), + ('\xf562f7051a848df1b5d02d1966b258e97399b00b'), + ('\xf5647369430510903e82cc38e4f4a169e46abc48'), + ('\xf566aaa24d8d005b5f6aab9dcf0d5f945745f8c6'), + ('\xf5718a8aaedec363680f5f2370003b264867fd8c'), + ('\xf57379c902fdafcd38c8f98c6806e1946fe6bad4'), + ('\xf5786022f18216b4c59c6fb0c634b52c8b6e7990'), + ('\xf57cbf4fbda5018bf1f90c75bbcf4f980ebf0077'), + ('\xf57e7f9445f2fe6629743227e438a74d5af4b638'), + ('\xf5809746654e2ebe4d5d6a275a70291ee0f006f1'), + ('\xf5894199d26ed391da2aca47445878b6f0d2dbaa'), + ('\xf589a835209b5332dd954a4e28660e3ed6a9c20d'), + ('\xf5a503820b2aae04a6bb216936f3482c8f541a5c'), + ('\xf5ad5c6a085eca4b33fb781b9cae572952ae25af'), + ('\xf5add23931aef6e1c34054c5db2f21600f76a5e9'), + ('\xf5bc0a9202870299a0876e57b01d47e9819127d8'), + ('\xf5c1da6c6e16ffa081fe713f8a463349e8337428'), + ('\xf5cb70a0f9d221487b887f98c637545809cb76d8'), + ('\xf5cfe2cdacb4eea74b3d31d4bbc9ddd3efe26bc5'), + ('\xf5d4e8236b31147c6bedcb6b372511d1f9faf641'), + ('\xf5e23c171644484945470e60155d52ba5c5b4ddf'), + ('\xf5e4cbd81afe4e7fc8cd3a8d5667a090d657baf7'), + ('\xf5ea884b00cad73d521600a06fef5379e021752d'), + ('\xf5ec32f9e77ae657c1f491d46d9048d740b9cf09'), + ('\xf5f80ebcd0e8199631cd46417172a1943dc35ea5'), + ('\xf6051a64cfd002d4804bdd07e984a0e38611b113'), + ('\xf60957ef072cf8e66cf8290e2a83857006c1d6f6'), + ('\xf60eb670bca3ec4ddad03dbe47fca5bbd6cb3d3d'), + ('\xf625b5bdf8adb2b362a4d7442f3cc774b4c591e7'), + ('\xf6328509f7a74e90eadf7e70f5ca33bcf6214084'), + ('\xf63b78fdc2a620564a54d60bfe68dd79d4ef090b'), + ('\xf63dfcf400e2dca2bc36b63ccaf88506c40cba6c'), + ('\xf640a62bea7bfa358f323b82ba8d557f77cb6781'), + ('\xf642934659f956338dab3e0d844c219ff4858c62'), + ('\xf643f1cca5aab01bdd6a309ad69b47ab21174c3a'), + ('\xf644ca0d52c7ba528905daa3c7848275d1e3004d'), + ('\xf64f50b145cc55f4b6188b4e6dc779b950d95969'), + ('\xf650fcbc0c4f3fc9688172d9a9c498dc41447b82'), + ('\xf651547ce268b93d61e70a5387717f2ecca0ee6f'), + ('\xf65353f3426c161bb1d0dbd27d7e328c4555c600'), + ('\xf6554a65d83b6ea0d665a892fb480a6fe078464c'), + ('\xf655b2072b88fd835d76e098c542b9c667573eec'), + ('\xf657ed72a7a7984121f9c85cb92c1d0881142552'), + ('\xf65eba2f3bcf738491c07db09fd0966b6ab5d8c6'), + ('\xf668a5ea728ca87abf8b896ffd1b1fed7595ff9b'), + ('\xf6825b83f0bf172b006cc0d1eba958cd39dc2145'), + ('\xf68276da8666104bfacc033671310de1ea440b27'), + ('\xf686a6c6c9a7bc6abfb0615e9517bc19b4be1979'), + ('\xf68e387679a4efd550160c8e2ae92e5804319326'), + ('\xf691af243f62bdd5113ae5129cbb8def8c0e8ad0'), + ('\xf696b8c4bab11d4fe3757deefd8cae28a1bd884f'), + ('\xf69c3b0b7fa5166a6f430b7fa3be24f6f4b97aec'), + ('\xf6a0925f04f8278c701e157a5fcd22b56949e4aa'), + ('\xf6a51f8acccaa91171fc45a199632e8d1bba8046'), + ('\xf6a86ca54840c6bd1a73b6ce6af8a485a323d3e6'), + ('\xf6aac592e1676e2f1af2e07529767cee88cef170'), + ('\xf6c08d180ea9a63bb1798d6f89a657a16f990b87'), + ('\xf6c0bbe399c6f3df3b3d9ab99a38dec565a5a509'), + ('\xf6c5096b50218313f3a115b8bf6d77104c008507'), + ('\xf6c56ed7420956ddc2fd3343317680d200e7a816'), + ('\xf6c767d2a93b45773e3e22db10679b32c9da12ef'), + ('\xf6c7d7c26c7a6c6c1b480cea0725ae53dfbe70fa'), + ('\xf6c98570286feb9e2cfc1b1056e5503704aa45ab'), + ('\xf6cd4d009db6075adf7aa9dfc38a8d09672cd89b'), + ('\xf6ce6a801c258cac75610b7f8d7dc56b1d33fbc6'), + ('\xf6d19c37388babcc429e7afecfcec5a2f6abbaf2'), + ('\xf6d8c6bff7bff9fa4c57a6350c3abead50e9ac79'), + ('\xf6da4eef2c5638c88ca3eed7bd2f9453b13e1979'), + ('\xf6dc4b904dd8ef1b416465fa4aa95680a1e5efed'), + ('\xf6ded6a5690fe373a47aa87b7cc64ed5837fb5d7'), + ('\xf6e4fbb2e9a3288868275c5a24a0c16ea8dc921e'), + ('\xf6e7cca7ab4e42389b0f50051b71fdbdab1fc16b'), + ('\xf6efe94a9351f497043c0d698776f45ca9a8bc65'), + ('\xf6f1317b5eee72cf54b9d7b6ed6eadb5085f811e'), + ('\xf705bbb11c77efd4cde366db8bb29cde393c3ed5'), + ('\xf705bc4f75aa9af810ad76763275122548798411'), + ('\xf709a0fd41909d0137390646cac92ac91dfef17a'), + ('\xf70baf25096cd9e66218922c3322dbdb4e8e88d1'), + ('\xf70bd900d3673059c010e599878b932eddf6f0fb'), + ('\xf7132c6de2bf3b3eb27ecb3db8ff0353b8a2d349'), + ('\xf714966174498e2f93973e3d5b05a592875323f5'), + ('\xf71781b8f995a936ae0222609a085e46468b3e37'), + ('\xf71a71d5f8932047d5d1eb966913e17daf8fa428'), + ('\xf71b0a4e75d6920e5984623596c8c6eea18e7138'), + ('\xf71b8bf6eb0b6f24568cc1895850f117359f0bcb'), + ('\xf71cc52b253c328aa54572768d27adcabf431256'), + ('\xf71d48807de483269e92652bdacdaab09f4be9b7'), + ('\xf72c0711fecade94b529550d0d3dd681d35f8af9'), + ('\xf72e6e6f46edaf7a2f630091553a4547d772e05d'), + ('\xf72f1615c8fea2a72461a9e79a42a81a9e7abfa3'), + ('\xf73d7323abe3bab9f3b5d60a0f87c365e7fee400'), + ('\xf751bbdec4a66876e7f20f86f745a7ecea0a315a'), + ('\xf7520ccb15f4d7d8a1717266ec0b84f32ea39368'), + ('\xf7529a0a810e3d5d9c1f907bd73de146ee870379'), + ('\xf759e75c0d37b4ab637b25d7629543553ebbc598'), + ('\xf75b6da1e375b1cb0d1f0d4c02889a1e52a58472'), + ('\xf766d24d44605e8a41be161e44caa91dc0620121'), + ('\xf766d70e5d08a1506953b95f01c89cb0361effaf'), + ('\xf7706f90b2e309476f3d29bb43a50b27800023f0'), + ('\xf773b51b58dd0d2f53fad0511b873f1a7f7dcff5'), + ('\xf78540d6bd76f9f39b92aa38752e6771d9923d76'), + ('\xf789fb8df58249eb3b4f71c64649ce920e252be6'), + ('\xf78de33ca083003798a7983dd743c0ff8175eb93'), + ('\xf78ef43636b7e094cccf7ac8c57eb142d9b6d604'), + ('\xf79017ea74a6c930d7f473f08f1d0b9d1b9e90d0'), + ('\xf792d5ec1b34f8499c4456f08e3f56f7338c68eb'), + ('\xf793ae6f50d23e5472b5c747b9350ba398bc1d80'), + ('\xf795ad1863b8bd0bbf4cfecd37e002776d9ffca4'), + ('\xf797decf7bf4138b289ab610713084f0adb2087b'), + ('\xf79a528e3ff45636a92fa5cac2c4ff2e64642d8b'), + ('\xf79c4600678bff3134377783b166f626f9b8446e'), + ('\xf7a0d5a207cf8fba6c8199e65851a973f5a0844e'), + ('\xf7abcd14e1172ab8c9679062d3d61d6c18aba5db'), + ('\xf7b95b696234eb99505596192554db2f03736594'), + ('\xf7bd9cf3a607bae75213e1f7bfa7e7a73924e04d'), + ('\xf7bf505572423cbdedf61306c6f5f7cbf0c30a69'), + ('\xf7caa7414392a7420f9d2b393df39d4b0a1e6f25'), + ('\xf7ce69670afab6d01defc197ba55fb6e9c945920'), + ('\xf7d36dd69591a4e9f5f0576c04a020183f413a21'), + ('\xf7db4acf7a5cef7bca063b8a1442201d4a2cda1e'), + ('\xf7db5ffe9a029001b860141f5e276a175b2ae0a2'), + ('\xf7dcd3978144ab2050fb9ab605c56b5e4a77546e'), + ('\xf7df0f1309bdff80b817825d6a85d337ad0f7528'), + ('\xf7e263436ab37bf96e2c5322eaedb83dbf4bc408'), + ('\xf7e35226f690613f7519b3f879fda6ca6e000603'), + ('\xf7ec0677ca4f8e6d2caf4b1220a90627bf13aade'), + ('\xf7eef72b5e8a76a0a0d9b263c50491e44defe4ff'), + ('\xf7f58dd68b778fd8e56ac2421b6e66a10007d352'), + ('\xf7f7f7b4a39a800de69175b5ed72a83e5723685f'), + ('\xf7f845aea4b20335bc79013c9d3b2ed40507d72f'), + ('\xf7fb7a3288ef84f20d79f2c95219a8826f743fd0'), + ('\xf7fce0b8eb4cc76b5e7f6d867686432b31312616'), + ('\xf7fe859e75560d3865bf5c1e7f0c056db1475b4b'), + ('\xf804b4f1cb089bd630c46209e65815306c08f5f3'), + ('\xf804e5b17636423539f11557daa0874e6d3f2f03'), + ('\xf80b82a0d0c874a96f6b43df7bcebb5299e96214'), + ('\xf80c32b26bcf4976dfc8d96c64719c88b72a7917'), + ('\xf8113f4b472114a39da4f3be7c42889fd00f1a9c'), + ('\xf817692998f63f88c9ee3e0476cdfaa7f4da1e2f'), + ('\xf81cd9b15a4002fc8536ef620fc9d692008c9ac6'), + ('\xf81d24e289c04179cef3ed0f539b8e0d8462d3a6'), + ('\xf8281bad8a6d17d4cf680d771b8df27349346cc6'), + ('\xf82b50edb3e03f9a5fb6c1fd7f95d498e6e03167'), + ('\xf82d1cb56532ac457be4d7fb4c051339e863af0b'), + ('\xf82f6d3ee1fee366bd4cc6e00c8fe15f9a498bac'), + ('\xf8303842f7d7693ea31507ba893a4ba371a20faa'), + ('\xf8346ae9053386f1fd1f321ee0915e8a53b9d5ca'), + ('\xf83ce92dacefb5cbf7a9654d96c041d2d6dd2067'), + ('\xf841c145ba488d5984858538ae64e98c5dafba3e'), + ('\xf84680cc4f494b92eb3691c50f528055f132e0cb'), + ('\xf85351f56018081dc986a570050aa21751dcef8b'), + ('\xf8535299f282af122abfc59d87174f417aa6852c'), + ('\xf857e256cd78e08a01fcf06ff83bf9bed6d5d6d9'), + ('\xf85fa04132ba7bf88256e5590b6565b88dc74221'), + ('\xf864d08412226958bf71c6d6342efde1a859856f'), + ('\xf8696a4e183f7fb187b422e0dd8dc6b93180948b'), + ('\xf86bf53e6f64d2b33ea269d406e5ca1c9b60d72e'), + ('\xf86d6fc7c99dca7e947e6981abb7e09aa97fc2a8'), + ('\xf87145f0900cebde5c6e4b3aa4e8ff9876f1ff80'), + ('\xf876217f986bf9718476107df2d2ffd7bbc9ad05'), + ('\xf87a53e9fb9f68cc0318f3945634cd2cb37830e6'), + ('\xf87eca13f4e04dea7d9b4e8bbffd5a311a215601'), + ('\xf87f308a17078b481900b50b7a663e862f5bf015'), + ('\xf882ac6e863ba28a3fd112156ce6b18808921824'), + ('\xf885ce7f619927da12ff976e01d5a0c56892890e'), + ('\xf88a03397481e9f92676391a816cdca60ef39101'), + ('\xf88be1c134e75e76678176ecd33d24ad602da874'), + ('\xf89ea600d7baa93e22301e76bdb29daf794f2279'), + ('\xf8a138955f413bed1094a114610a405cf64116bf'), + ('\xf8a1d24a68612a265389223f446c879fa55f0bc0'), + ('\xf8a26f6c0eaeef9af3daa94f807e6166128b123a'), + ('\xf8a282fb32a3f2c47a23d008066cbd29ab62982f'), + ('\xf8a2cc200c070427bcbde7dd9194bd4b750c6866'), + ('\xf8a6583ba48c43ac196f7bf34c3c935a9874d87d'), + ('\xf8aaf69b2d626b34ac803689ca259a2b7f936681'), + ('\xf8af7298e3932ddf6c22d6bad9609bc2748803a6'), + ('\xf8b0659c46878bb97ae8b1070eacc82bce02beba'), + ('\xf8b9f1a85a985dbc1856574bfdb669a99bc0ece3'), + ('\xf8bb5b91af0c9cd00abeab447851a1e05884c353'), + ('\xf8c57e312f296372bd93e18f67edc6012c4a964d'), + ('\xf8dc85a0e393012142dd3c7d470b98f0b3e315d8'), + ('\xf8de8c3ad87431dbc794c12ecdd85a6f1936a0ac'), + ('\xf8df2a813bd5cfc5391c7e3ced9be79f3850a9f7'), + ('\xf8e087f3592f6fa2076d9b2ba4ea0aa8096a9b34'), + ('\xf8e3871e7dcfca22e576e654fa87e646f13bdbdf'), + ('\xf8ef5624f594879b9d65c11684875dcc4e9dc6e4'), + ('\xf8f30f5c1d93f236700c7b478ab51e3485f6221b'), + ('\xf8fa75f1324906fef2c9403bdfa70aa29cae1ea0'), + ('\xf8feb0392034e7801b8c545365eaeadff8be9896'), + ('\xf907cc8b5909343eef7fbebbf1d7c50d0022e7f1'), + ('\xf90d3196d934793f82e8ec8bc27909366e84973b'), + ('\xf90d5a2bd22dd1d7a1c4194e5eb01b620dcc36fd'), + ('\xf90e7732ca2343cb150bf36a1e9207c77f5512d9'), + ('\xf9189ec57ffd6fc6c6068f0c8ddc2493634b4cc4'), + ('\xf918e60bc91f158a9015559c2700d7a871abe1d8'), + ('\xf9229a9f190ddf14e4e52ff349db3a7e5a7b117e'), + ('\xf924e68a9b1d521f2dba1ed23f3bf29b4c405c52'), + ('\xf929076707f77c82cc53f5f39438e6740ea7667d'), + ('\xf92910b6ad74528f85ec749254cbebce9530aaa4'), + ('\xf92f0b73edd2870c3e84defb1598d7ce06337613'), + ('\xf931b5659acc9a67ed040c73da8953c2f56c2e2c'), + ('\xf93202427212eb38eda5a2a1d3f79a23972c0d1b'), + ('\xf93f85bcd82f8d8b3bd88e8df232f9827fe2a16e'), + ('\xf93f8f7a3a6c6346a9e4519f9a200764268d660f'), + ('\xf95041566883afeaf9416a7a7335e7c927aa7d25'), + ('\xf950fdf69b54b8498bddbc380e0ffd0ae0f074f0'), + ('\xf95a645ad92763b4ee5fa84441e7f8e260167faa'), + ('\xf95cfa29e6aee998d9459210de2d6eb85cb4ed02'), + ('\xf95edb74be83d96ea75807777a74c6ed1c885320'), + ('\xf9608689652811303604f3e53c2c6a89266a1ced'), + ('\xf9630f7988f9b7b636b848ec82b917e546f32fc6'), + ('\xf967892169825d80b0409a91c267a0533a2db3fa'), + ('\xf973e78df1149e17f3c52fc8dd07e893238148fa'), + ('\xf9773c7e0d8d31fd1ae6f963e3d53ace16d15dee'), + ('\xf97a2cc3fa1358a64b24f2abd3a5382e73d11c01'), + ('\xf97a5024a7350b8e821c15210c41fe5c7ddbf8fc'), + ('\xf97dbd0a7d8d09f5a692f247c7a560b95d1c3172'), + ('\xf986a1b16bc845322d5bbe95b08e82581d456b7a'), + ('\xf989f1b625d8358f255d4a93648a595880888dd4'), + ('\xf98c323d5b7844262fc60daf65d92e64f0fc8e02'), + ('\xf98f1e9e778ac27c03e514155098b250f926e916'), + ('\xf99718fbf7221d7fe8b51ad636446adaf4162081'), + ('\xf9a09e4a870cf174994b12c459173f10a8f35d1e'), + ('\xf9a0a6c547882c7d5561b9fcc990011d20905af7'), + ('\xf9a2b5911479470206b69628463c7df2c4a15bf4'), + ('\xf9a88a038e397668d370510e1e471cce0f3050a9'), + ('\xf9aae6efc45ac77d70766fffe3f4a5780e2d49d4'), + ('\xf9bf3cca18df5226892678df3d5c5d01cdb23bc8'), + ('\xf9c03d79f4fb8df4cd260e69258dbba3bb4dfd04'), + ('\xf9c13557bb1ef491e1441d581c5587743f0f06a4'), + ('\xf9c913e457d8147f1455d2e81fb97500a8c107f8'), + ('\xf9cbc54cb4002c8f6b60a76d35abfe47888bb503'), + ('\xf9cc355be8d14ae6fcca25e9c81078c70aa44b95'), + ('\xf9ce47b2cc7981bef247088b40498fc8257859b6'), + ('\xf9d6655bbb53cd75ee5d6eb88ef60432e92bffad'), + ('\xf9dfbbcde07a62657283a5c411c98a7768954c5e'), + ('\xf9f2809d0c0363b117540a879892d8cb81709070'), + ('\xf9fa6516d918b655f5ae6c9bfd0140eea68fd04a'), + ('\xfa07ad9c8c7e45164aa11368dbb3877029eeabc0'), + ('\xfa184606f5024e5a0708bb345e3fec2e2a6f0d29'), + ('\xfa1bc780c28864508f3270163c91d3aff83eadcf'), + ('\xfa218203260e9253e40d06d02cc1760535f5e86d'), + ('\xfa223e401ab66829cba2716bafe2c465d1045e86'), + ('\xfa23ce12df127bc1810ff1fac2ced0e6e6d242aa'), + ('\xfa2779d58984b7d57509a2ca9ce37e206b0df01a'), + ('\xfa28d9c9744e5bc8c75eb125fd7de53fb7ab2ff3'), + ('\xfa29036b26996fba6a5b18c36d4e115bf4ec656a'), + ('\xfa2960fb023e9867067240df48521a120d27c06d'), + ('\xfa2d099b92db49914d2cc41a0f1f27dc83c42071'), + ('\xfa2d53fa28cfb24a24256937cce369681f0102bb'), + ('\xfa2ef515481682c04e498917182dba8ffb3fc90a'), + ('\xfa30f59cc4ff2e5058bae3e3a0b045b2c87d08e3'), + ('\xfa337cceb16c2bdfacd9e5eb73af4b214ef5de86'), + ('\xfa36066f7d231bfdd8d847187e5eeb66f6fa9807'), + ('\xfa3bd0d05530c3ff97d3e9fb57c7734191c9613a'), + ('\xfa3d658eaecf5942cde96d19e2060a1fe8d4db2d'), + ('\xfa3de079306d27d55fb6c04143e47dda71de91e7'), + ('\xfa4066c739647b88dcc1ed4108bdb8ba476bf3fd'), + ('\xfa410c0d5ca7e1c92ea4fb0bf67deca2b3403d5a'), + ('\xfa414aff9ed28262884309c66b0d1fb5de5be4ea'), + ('\xfa46b6f135bfa3848b451d284286beb04fffce89'), + ('\xfa47743ea3e79157b05ac0ab2911c4d00548bef5'), + ('\xfa4ddf02d4d5add020f229acde57502feca0d360'), + ('\xfa5273282a1df83716b11691a806318a8a858d97'), + ('\xfa54751181cc6e20002e98ae698b8afeebd6b49a'), + ('\xfa5e9ba0b884e38ea8f935af333b1829878317ee'), + ('\xfa6dc9ec0d427205e42f969b3a2543b4028da36d'), + ('\xfa6eb8db7387ea1a4eb09ce2469dc301404a0f4a'), + ('\xfa7426ac78790552b1d13f1844160e728bb30ddf'), + ('\xfa7e38f55ccf2d997424efb61cf73be75978362d'), + ('\xfa82ae4ee56e2a02b7a232f8d06b815f7d532181'), + ('\xfa850d32dee6f587db9020a1cf290848e3397256'), + ('\xfa855618673b9727bf6f136a167ffa217957dc85'), + ('\xfa8697e445092a3b089eb2e7b57c98ff8774e3e2'), + ('\xfa8814ffd064d172e9188c4b08ac60041c43a383'), + ('\xfa8ecac150c9d3bb55434bfa1e424b2aef33348f'), + ('\xfa8fdc48ddb4944b7156d8c1cda7ac160e69498f'), + ('\xfa9a22540c748efa706be9334e722d73f9e30248'), + ('\xfaaf9b6bf835c38e1be2aef070b8e9fb0112e42c'), + ('\xfab3da23f7ef14fcd7ae51580c7ec6c5644ebca1'), + ('\xfac3685a21aa5924e179575068a57f80f2d99362'), + ('\xfac47804a1c42ec58bbc79e633f7d80aab4b3b13'), + ('\xfaca23745260bb5ae8d57549b972335c2347c384'), + ('\xfad404b80b0abe636964aa59910649847f1212f6'), + ('\xfad46ce0d87e10a761e23a09c33b227663e2c3f7'), + ('\xfad71334e835b5c06b713b92f4e56e6aa6e4d835'), + ('\xfadd08fb986a24cca4efd9c740bdf2b079062a84'), + ('\xfadd274d2fb2781c6b8035d95fc76b6b7650338c'), + ('\xfade0c25452aadee0f7ebfcd296529acb6713329'), + ('\xfae517cbdb4383dd1e02ad701c475b8b0de52755'), + ('\xfae68d19253ccdef0a3e07f71782ba940e0ba894'), + ('\xfae6d34d3167f6ce5eb4000b96a68ab65a25a848'), + ('\xfaed7947f77f8a80bb0cfbec6d4463c1f79b8573'), + ('\xfaef62f4f2424c14dd24773d475d7618d3c6df0d'), + ('\xfafa599d7afe829aa3636ce45ce85a3eecf3fa0f'), + ('\xfafc6cae2e4a39d9db52c17c4cbd2a0fe43ac4d2'), + ('\xfafda954877d26605742564cfe8da7000b24bba8'), + ('\xfb023335c68c201184603b3ad914fd9e1ff016f1'), + ('\xfb055e109f32f76a08a1fc3513e87c2c51b9cb2b'), + ('\xfb0b70fdcd10b2ea0b356d9a25484ed04cc26634'), + ('\xfb0cc49f7fac6887844e0e0218bf602646870af7'), + ('\xfb0e70a7424333e0e450c05c9dee9734123c179b'), + ('\xfb0e7a08927e50b50fa0b36f4f589abb2caac94f'), + ('\xfb0f2b09ddfc4edc58bc1b787997cb184278e58d'), + ('\xfb144a470b453a91f7e3801291495ae1eb0eef5f'), + ('\xfb161c12060bc6be695868e2d6730a89f94acb2b'), + ('\xfb238404e5202d14258b2b8cbb2ce7d5fd7c58d3'), + ('\xfb24be2437c954f79ac3c890b9e9a545735b2450'), + ('\xfb26bab044001d64aaff5b65e19d1f3b14ded93d'), + ('\xfb2b3afef9df55bd9f2c7db00f7b7505565fd945'), + ('\xfb35aafdd6866dfaa60120d93015f25cdcf7b4a2'), + ('\xfb48ffa0effebc746172d72bcd81c23556d0e187'), + ('\xfb4d5a6ec59045ee13a90d4114bad75a2a5c2f3b'), + ('\xfb5167c8f327ea366cf2b3e7f24e5e347eeabb76'), + ('\xfb5f0a0ec78bdd8b77c90db6f303ccd38c3c2450'), + ('\xfb5fdba4831353124c332fe5bc1d18e2ce7c398a'), + ('\xfb623d7af8bb32b35b6dbdfedb339094b18b2768'), + ('\xfb668b8f85e122899c56daf89252fa4cd779dba6'), + ('\xfb79ff8fb22b3847b5ce57af173571839cc9671e'), + ('\xfb80bc99b1f26320e289ff5ac79668c4d4142a1c'), + ('\xfb856fe520f03272b580a0206f7606b83d1f7991'), + ('\xfb8a51958fdff705aa629845769385883fd87740'), + ('\xfb8a6649021da64bc360c0f95d4d86915cf008f8'), + ('\xfb8c801ad5ee08efa722600f61c74a7e5990ad34'), + ('\xfb8d768811e36dceb08342f5a465d35f7b7d17fc'), + ('\xfb8f668a40bb0142eafda3747e22aac0fa6227c6'), + ('\xfb8fa9c9d0b978606d61cfa0a8fa20b99887b280'), + ('\xfba5ed9e766c27ab46af28dc3228bf684baac38d'), + ('\xfbab16a034a6bf6520aafc334bb1b2ddd4f95f41'), + ('\xfbaf21b4f2c8e7d031b0351879ecb6b114a95db6'), + ('\xfbb244580102850ef8a91d96eb8e82f7a3e0df6a'), + ('\xfbbac8edd767b05321f5c796cd6b2bee8e580a07'), + ('\xfbbf16a8c7f893522f0c574a7af97cc6bcb5d31e'), + ('\xfbc6978296623bb2b93f11786f4d62f577bbbc3e'), + ('\xfbcebb568c7146478a4a96c0299d78173ae2ae2c'), + ('\xfbd701269adef1d44de4980de03155ce7d4abfda'), + ('\xfbddeb6ab7d0dcd816c61f90fc274cd60164ea87'), + ('\xfbe39703611c0b7ecf1b31059002c0cab98c2218'), + ('\xfbe794c555ddad4ef46689b0aef47ed2b19c0cd0'), + ('\xfbe82014b8fcc293d82c2a203534af5d0eb89663'), + ('\xfbe96f813dcd6b3fc43af84d13c64f750b10b3db'), + ('\xfbec3ff0b645303d256caa0f4ab05e1fbf333488'), + ('\xfbed5e2de5ce34b1a9e861684694f7038349cee2'), + ('\xfbee99f99f0c636c9a9f1f16198bec73e1389b37'), + ('\xfbef958344acad987aa79f728b00bf083f6af16a'), + ('\xfbfb7caf4652f998ca258c2edade374d020f5cbe'), + ('\xfbfe8065e0ae6fa2bb998aa0f769ac154bbc5a2b'), + ('\xfbff51476ec003c890cb6c07c4de2b6e431dfd23'), + ('\xfc0b5a09498c08b75ef18dec4d1ef290f42c9da1'), + ('\xfc12259931816b096ed9ad4f9e650a224da2a4ad'), + ('\xfc209ad9c860c95ff8949ddbcca4a5d2705c4ab7'), + ('\xfc218358b8305f0f857bd2684999fcd00f60f900'), + ('\xfc264303f2d3f1538103396f57b1dabe17ebf7d0'), + ('\xfc27eb2378e5ec73a38b414f1a2db0ae066dcd82'), + ('\xfc2b20bde48237a054d654ec025a43a68f28925b'), + ('\xfc2d442044b9a51c416701abc1c4cf9398f7a8aa'), + ('\xfc3946d3dd3eb828e86cddb9b5851435978a509f'), + ('\xfc3b9b80a90790a54ba233f5eaced3f138ea1269'), + ('\xfc48397e3f06a283bcff65789cb2e0b3c7747070'), + ('\xfc49294e18d34e79cbf68f3f1a178d66edfd150b'), + ('\xfc5395fde93fe761bd2c4c8fb1f14dd45858fef1'), + ('\xfc54a84b51bc847b582540bd549af8acb5ffa996'), + ('\xfc5e2a4d90038d20145f061a120b54b369b7314a'), + ('\xfc6b7028c730b27f0b9e4654e0b617de7bf31c68'), + ('\xfc6ca7d49e92085dacd3a54c8363739f24e48a71'), + ('\xfc6d804ead600b456fe16c873b645e374d27f659'), + ('\xfc750008def11553fe4444d9787b6281c78b9ca0'), + ('\xfc78fe7ccb9277d7099881c68e3c2fd66c53bc8a'), + ('\xfc7f3f2826429dcc9723327b44f3f9c374ed2e24'), + ('\xfc808cc9b84642c0e1e4686d5ea73e7b982ee312'), + ('\xfc86bfcec68726c47827a854fda0ddbc3bea56d6'), + ('\xfc875e7ef2ebf605465cd80c1999e4f1c3efd217'), + ('\xfc8841e6386eea3630144b9512e5e45b8663f5ae'), + ('\xfc888dbc27c00a4bd80b595c85707c1b6c1b90e7'), + ('\xfc89b4f955ba74cd9634f54b3fc749d3fd4bf1c7'), + ('\xfc8c0bd2f9ea3a25e35ec7fe71a23aa5688edc8f'), + ('\xfc8d6090ed817d6c0f6351442e2cb8d9d157bd6d'), + ('\xfc8ec43c86340714f6eed800563b5a9ee45f90f0'), + ('\xfc9603cdd682c014b99a944af73dc370dfa2e530'), + ('\xfc97a52864a47c2520b5d1a42d2273f7a5749d13'), + ('\xfc97c5e550cfeea11196445d6c8bb067f8cc08b9'), + ('\xfc9d2364b8699ad76bbf746bcbba1b69f3c0d092'), + ('\xfca966ac658b423176d783d3928494a9471d30da'), + ('\xfcace38a07707c1365e0f0999003d900441089b8'), + ('\xfcaedccc469dd5c39b020e7d10041270290554d3'), + ('\xfcb0be4fa08dd02e4f8d0a033edbfa8c7c0d9576'), + ('\xfcb320b404d93dd9d7e81d1535971d7585066f0c'), + ('\xfcb3d89b68f68ba561ffa4ce70779b528e56c3ad'), + ('\xfcb9e8ca3851f81174a33d44e7a1171135b36e06'), + ('\xfcba96d8253749cc4f027aab0330b20111c962ba'), + ('\xfcce052bc032853982c6150d68adf2b7f900171a'), + ('\xfccf9fe4741baf1fc44e7b6de42c5ab72b119fb1'), + ('\xfcd6a20c23f0c6bee2d1d06f3791ebe2c6e498c5'), + ('\xfcdda8bc334772f445ec93f3c3ece0249ebdad42'), + ('\xfce7b4314c81c0e7b630f6aee4fd97aa5c91ab17'), + ('\xfcede865644795a0f9fe77f320f1c4ec593e6031'), + ('\xfcef647804b9480145a1689a90f0a569acc7105f'), + ('\xfcf0c34ced8cf8fd0ac030c8a5f05d3c61265f67'), + ('\xfcf88ff03dbc262883b11a81fc52bfa437f8725b'), + ('\xfcfa7caf02dcd462fcfb25aa2831afae3d448930'), + ('\xfcfffc1caf5295c4137e3d91de8dae8c65b3872d'), + ('\xfd032ed2583842df2b8c29e7823f6becadfdd5ff'), + ('\xfd057983d788719499bf93702f37e237eef5461d'), + ('\xfd08936e5dc4fc7e38dcbdd8751fd9ad595ecdfc'), + ('\xfd0d4bb214efb086ccb02161e2302ff06ec33893'), + ('\xfd14e842981ef5ff8de7d0b46189b83519f2a391'), + ('\xfd18f999ac8983aa789e9942d80110e88c12a8a5'), + ('\xfd24e0dc0ee504fa73fefe097054edff5bd046a8'), + ('\xfd26b47dbca72dcc604117a7c5011c098f43f757'), + ('\xfd32b0180808cab9cd0bb420f68b2239212c2b2d'), + ('\xfd3343a215a5d3203f1d8439573500b54fb27cc7'), + ('\xfd3a86fbdb2df9f54b3e230665e025a44c8fca41'), + ('\xfd40910d9e70d6412e5e9919bb62a2d649c27a7c'), + ('\xfd41bcf1c4cba814a89bd45b4f5cb814c1d4a70f'), + ('\xfd503493bb4284218c7260d2991259192cfb4bc7'), + ('\xfd58180683f6011b3e6f0af1ebf2ca6be1ca9d5f'), + ('\xfd5e3a48b0aae16b2c057f83a216a86d82170a6d'), + ('\xfd650c0454608742b00510e46d29df17cb2b582d'), + ('\xfd6c906ce15baa7b94f3f82c9245fa9608400d9a'), + ('\xfd70195fb5208e16eb05c2ac1f9260346675fb6e'), + ('\xfd77259724176d78610a6b867cbd8f42ef8a4067'), + ('\xfd79d43bea0293ac1b20e8aca1142627983d2c07'), + ('\xfd7a2d4cb7b7e7c53b55395919a211f78a566800'), + ('\xfd7e23a6d6ce16ee1a872fd9cc1bcc3a9778114f'), + ('\xfd7f850160fbdf73535ddf72ed642ad8b50f2e2c'), + ('\xfd819c4ce8bc9c80dea58467e877dc22070d7d9a'), + ('\xfd85c8d3f6c403421bada2eafa3f8c10f019d3cb'), + ('\xfd88725e98546ad618d5b26dfea81c34471fec25'), + ('\xfd8c3fd200d1fbdc18c992da966fe8ca01154098'), + ('\xfd8d3d189da29404771945c838282c08ccd51d38'), + ('\xfd8de03f807d4713e32c85c5b93d076984bdd4d6'), + ('\xfd9f797efa1524fd1cd5ca58dfd7d21b7c74b7e9'), + ('\xfda34ba5c2e71f0e2b52fde3e31b1254e6757f55'), + ('\xfdac615cdcb1395e9682d25454f27a5e6888fb2b'), + ('\xfdb0d624982d55a6d19c76307b373d6b72001cf8'), + ('\xfdb5f182ef4662610898edf09be15cfa85933e50'), + ('\xfdb885c3f75a361a0c07cf7287589a417a6244d3'), + ('\xfdba81b2f6e3599c5bb95a4fcb8cde478107aeb4'), + ('\xfdc4390580d60f7f65b709aa98b14d82445036d6'), + ('\xfdc59088e656c5b819b25af675dd0ece2373e511'), + ('\xfdca20bc0ba96d6724a08128117556b69af9f0ee'), + ('\xfdca7368dfc0685e8b52dad07a5cb282d234df54'), + ('\xfdcd90ec1fa6382a3bb00bc429af19ad3167cb1b'), + ('\xfdd032e7394cde732ea28e5145e274c289818969'), + ('\xfdd186c911418fda7caa23fec95ed3cd3c3e376d'), + ('\xfdda2f6662fdbf450e068dfeac8b57cc331767c5'), + ('\xfde50a172d973a3f3fc3ed0e6451a51735d23b44'), + ('\xfde99c77df81d50958d490865f7d31db430d52dd'), + ('\xfdea407bcfdf9851e5c94abeb12a2e8c2ea56ad9'), + ('\xfdfc23542f47a51819fd528d6ff32aaeac727ffc'), + ('\xfe0351e1a7111ec6778457dd613667041983bda5'), + ('\xfe0573d73bb16a701faf7fa95e5d21fc32c0b26b'), + ('\xfe07abdce1d799f700b372f12bf6ade37b9a2c4b'), + ('\xfe08a67cd49122c4da6b2ca2c9bd307de45fcee8'), + ('\xfe121990e0c54be2fd07588456d4a1fccbfdb647'), + ('\xfe13bedc364e63671a670f19df865d516f978541'), + ('\xfe15142de620f3e8b07dd766958680fbe2204041'), + ('\xfe166ab4952c9ac02d64c3b58b919e6fe4a2b1a9'), + ('\xfe16c1b2cf3e6ae0485c95addf6a6eb331a731c1'), + ('\xfe1e8e0674662570dcd92fc1e7ae16e8edb311f5'), + ('\xfe1fe8b3e20c1be5a4892b751b9268d693db543b'), + ('\xfe298c97e6bf610ca71d053e55bc44a35104169e'), + ('\xfe2beaa4b8e8eecde1f9e583da6ec1586f31e00e'), + ('\xfe2f194a68361ad610a280cb4a4af7c95d76cfe8'), + ('\xfe2ff0457e95fed12b1c64a73e919cc6c39402c2'), + ('\xfe327111d434771049d639474ada0120eeb1c5de'), + ('\xfe33194987d6a0c93b353ca1f440aafda1513845'), + ('\xfe388e59e29a8c5ea2240a6aef2a59c7b7d35731'), + ('\xfe3fd56d7cf370906f78645fcd68a4ba263e2d28'), + ('\xfe4374fe3cb326d9b0b4bcdb5a9227e55dd4de4c'), + ('\xfe4747b483c23ece3c8f78b93f5ecac0f027c6b5'), + ('\xfe48098734a8223ec79b5ad2f42702c1cbfefa6a'), + ('\xfe4bbad1c6030ed3fca2c7336897087060b21b5a'), + ('\xfe50835e2eaf9f90cb8f52fc5ef2b73045b07a03'), + ('\xfe53ac13ce3f6f55d29d7bf166368fe00dbcc6f0'), + ('\xfe540839ed1e171f3a417b46389257c416f524f1'), + ('\xfe54955d48006649517f57356a552c80a7832119'), + ('\xfe56f57c09ca694dd4a4750055439442d774ab99'), + ('\xfe57aa5560b0d5e74a5ecb750e5956d230a5aa3a'), + ('\xfe57b501307e314d0ce7bb40955c16df2c3c40c8'), + ('\xfe593561f142fbee2603d16d4bad68160643b27b'), + ('\xfe5d9fb72e5b3d01bd1b01d7d0ebb8ace3ff3a2b'), + ('\xfe60b87e7b0cad4703cdf8de7c89cba0649d3981'), + ('\xfe6621ea61de3dae462e32400f07479b2c7f78e0'), + ('\xfe755267c2e61133df01928c38bc55fdccac54bc'), + ('\xfe77766fb1b36a66dd9a7be198789f3532146a64'), + ('\xfe79f8e1a53a1c7a830a67c0c5b7f4169cde79e9'), + ('\xfe81cb7e1af2a884527d16d3e118cf519d976fdc'), + ('\xfe886ec304d3097d946da9ec771c42e121261077'), + ('\xfe8a22af76b792aa7553451be559e9d74de8a5cb'), + ('\xfe8dc5abd36f373e26b90f3da71b35031f71ae54'), + ('\xfe9b63b81e2d069b2316dec4fe212024c89af885'), + ('\xfe9c352c92b4be37689453460fc7bff4540455e3'), + ('\xfe9d102870f9c95d4f545d6ab5025c2cc6ae51d3'), + ('\xfeb602094c3f751bd27e52301c540eaa9db5a05a'), + ('\xfeb9eca9a76d80a41602a17b9961e28abb6068e7'), + ('\xfebc801044d1bec2ca1bdcfc11ff252e72c49993'), + ('\xfebe4e3ea21c6d267afdb3dc7a40d30ac1f92676'), + ('\xfebf5e1fc868d3022126b85da00e759faaf3224a'), + ('\xfec1f8aba1529284e7b50fcd044d5b9d522b7652'), + ('\xfed885fa9c171c97ba3a6dea934eb24ab5626a10'), + ('\xfed8f37013c337e7640b101af0dc3b7afd519807'), + ('\xfeda714160591877eaafa9a8e3f69cc563e9f067'), + ('\xfee375bda1586d0ef2356540b356540d5ae99367'), + ('\xfeea6930b2dbee316039355cf7d377d344b9aad6'), + ('\xfeebdb85efb0049c35028d631b8c98f9dbe3fe1e'), + ('\xfeee739b9a0d27d4d0ae2bdb6d4f31cbef5e342d'), + ('\xfef2f3fe75120a3f21b1deeed4169529f41dbf30'), + ('\xfef43c366c2382f3f30ea362d60e30c9f1c30e97'), + ('\xfefdd8250dfec84bf4c5a30f37cd19e6d18e194a'), + ('\xfefffa6b0b23b1c6777af7012b324b291b77d863'), + ('\xff010d65d1a58ee614e88e2dd35a203d0795858a'), + ('\xff0815cd8c64b0a245ec780eb8d21867509155b5'), + ('\xff08ee48996bee00d1163c184fd2dc0b6ff13e2c'), + ('\xff0d0b91b6ef41468c593a0ca40a81f9a183b055'), + ('\xff11093139174a1e2b1c41da1bf7a8505964dbcb'), + ('\xff13b45fa7ce2836ce8419e230b6b5fbfb078337'), + ('\xff1504bbd2e44c8dfccabcd3445961705f5fd684'), + ('\xff1889977f761701a512247ca1559a69daa3feb2'), + ('\xff18b45594b80decf9efbb45f3d3a600d17a8956'), + ('\xff1ef9fc6f71a198fb323d1df22a581fd1e92660'), + ('\xff2656731c0c8bbb645c4b46b418a9e64c6fd0e0'), + ('\xff27fc8f374780aefeae594c08478a0786167905'), + ('\xff29a7b0f224e6eed27c9254ce5d72897ccf7775'), + ('\xff2e5014510b80cee4e11deb59c66a405006d346'), + ('\xff2eace3e8dc2d207d633fd5b33832ddbfd53d7f'), + ('\xff318a2729cbbf811f18feaccc947c7b0208ec3b'), + ('\xff3194977cda5e96e0c47f4d0d4ef5aef95c0931'), + ('\xff3715aa5056a7195fa73e4948a79b56a03cc26c'), + ('\xff3bb2352b957585b55fe766d3bd0cfcd7b2e056'), + ('\xff400a527effb5757e8986f85398518f6b6450a7'), + ('\xff406407faf7cfc332f7ee664e9e125459912219'), + ('\xff416336a4130eb31fdbb6a4f09ce1ea3389105f'), + ('\xff4341975ae6af5896c4a3446c4008be289edc72'), + ('\xff44369da8682cc96e2d943984e24a7c0efa34a0'), + ('\xff4e760779acccf9f521ca976e20dac50269175c'), + ('\xff54e94130e739f7ba4301fbcf8351590c796a09'), + ('\xff5ec4cba4a353bff8fa2791b7e3a12e457c0811'), + ('\xff62497246cb20f0cf766cf21869bd6926067403'), + ('\xff62edc7b94d88157c62b78fd8d89823b70068b0'), + ('\xff644fd611b080a3c57a854e168557ac466adf7f'), + ('\xff6f9d0f1a38e233998d74122c66ab5c5faed4fe'), + ('\xff700e0077e965b05297e573d414f6f571ae9dc7'), + ('\xff707ddc8ec89bc0b1df4b1f9eef665f63dd442c'), + ('\xff732246f081d4294a5af53ef7a9800c4ca903d2'), + ('\xff732f4e2af57264d7b4c2446607e76f88853363'), + ('\xff79d8b01ca3bf289299d125d0b01be2cec5b9b8'), + ('\xff7a583bad77ccc7a562f0402cf10a24e7c9f4b1'), + ('\xff7b56d38098cc769fe87634c91b84481baece41'), + ('\xff80737d101ccbe09d45a0b7f3cf0241f2f68885'), + ('\xff8435690d4ef628e792d4b3d69f2c21edac1482'), + ('\xff8541f97df3a64cff34a49b493bdecbeb5ad82a'), + ('\xff8755fd7932512e7cbd281bfc137acf2fd90db7'), + ('\xff878c1cfe0a35786c8ab92b99d69e378bc31172'), + ('\xff8e5f977765da43163875788ba02be6a8de7975'), + ('\xff8fb252855f6befaa6fe7b5d61ebca59f260f53'), + ('\xff94c189d199a205f6651409fea994cb8037f9b8'), + ('\xff999b4fc934e4c85e429e110df1debb5677edfc'), + ('\xff9b58b0994ff60b3cf0403aa6a93587202444d8'), + ('\xff9c6a82062f0878be0afb4bf29124521fba8b06'), + ('\xff9fa84f51299c6ca35ca29b9538237120be16b7'), + ('\xffaf6cfa891872cf3ef83e40497f6bf5c7ba70db'), + ('\xffb737cfc18b11cf3b997ad15e553531f1f2f0bc'), + ('\xffb8e77fdf9898b503322015cbd9c3cdbd554568'), + ('\xffbbefbad0ab1dd802534eae62ed5aed1d854f2b'), + ('\xffc31725607d78e99b124e5dccd6b99600fbba3c'), + ('\xffc74bb90ebbf9cc843c3a1c4ce7efa84f154600'), + ('\xffc78ab8bf4b2e88869c1442add3769825b2115b'), + ('\xffce29b962fc0d2c2d400ff2d05fb996bcd1fce8'), + ('\xffd19832e5644d4482b1b2d53fdb3811ffac048c'), + ('\xffd7488e244897b0086fb7e6aa43fbd7d04e3c3c'), + ('\xffdd5670d67c4f5d244a0fd6e8cff913e9cb0b26'), + ('\xffde637ceff0bbbcb8a8e7cf30e4bff0f0e82300'), + ('\xffe7b29deba65d44b9126ffe33300b6038de765e'), + ('\xffe89720a22a747c9c6020b5fe85f496c224279a'), + ('\xffe93a1686957595fcc83f309026324290093032'), + ('\xffe9d8a251a403ecc1b29c29985a2f38a4c424dc'), + ('\xfffdbafeb73c497ea3d84107162ba05e829c59ef'), + ('\xfffe608c967afafbdd7be93b6d7704a2754864fc'); diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/02-set-global-flag.sql b/services/history-v1/storage/scripts/global-blobs-db-cleanup/02-set-global-flag.sql new file mode 100644 index 0000000000..577fcb5c7e --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/02-set-global-flag.sql @@ -0,0 +1,3 @@ +UPDATE blobs +SET global = TRUE +WHERE hash_bytes IN (SELECT hash_bytes FROM global_blob_hashes); diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/03-create-global-blobs-table.sql b/services/history-v1/storage/scripts/global-blobs-db-cleanup/03-create-global-blobs-table.sql new file mode 100644 index 0000000000..9e708ea1c1 --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/03-create-global-blobs-table.sql @@ -0,0 +1,16 @@ +CREATE TABLE global_blobs ( + hash_bytes bytea NOT NULL, + byte_length integer NOT NULL, + string_length integer, + global boolean, + CONSTRAINT global_blobs_pkey PRIMARY KEY (hash_bytes), + CONSTRAINT global_blobs_byte_length_non_negative + CHECK (byte_length >= 0), + CONSTRAINT global_blobs_string_length_non_negative + CHECK (string_length IS NULL OR string_length >= 0) +); + +INSERT INTO global_blobs (hash_bytes, byte_length, string_length, global) +SELECT hash_bytes, byte_length, string_length, true +FROM blobs +WHERE hash_bytes IN (SELECT hash_bytes FROM global_blob_hashes); diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/04-swap-global-blob-tables.sql b/services/history-v1/storage/scripts/global-blobs-db-cleanup/04-swap-global-blob-tables.sql new file mode 100644 index 0000000000..8ceabd83e5 --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/04-swap-global-blob-tables.sql @@ -0,0 +1,22 @@ +BEGIN; + ALTER TABLE blobs RENAME TO old_blobs; + ALTER TABLE global_blobs RENAME TO blobs; + + ALTER TABLE old_blobs + RENAME CONSTRAINT blobs_pkey TO old_blobs_pkey; + ALTER TABLE old_blobs + RENAME CONSTRAINT blobs_byte_length_non_negative + TO old_blobs_byte_length_non_negative; + ALTER TABLE old_blobs + RENAME CONSTRAINT blobs_string_length_non_negative + TO old_blobs_string_length_non_negative; + + ALTER TABLE blobs + RENAME CONSTRAINT global_blobs_pkey TO blobs_pkey; + ALTER TABLE blobs + RENAME CONSTRAINT global_blobs_byte_length_non_negative + TO blobs_byte_length_non_negative; + ALTER TABLE blobs + RENAME CONSTRAINT global_blobs_string_length_non_negative + TO blobs_string_length_non_negative; +COMMIT; diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/README.md b/services/history-v1/storage/scripts/global-blobs-db-cleanup/README.md new file mode 100644 index 0000000000..7460d4d6fd --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/README.md @@ -0,0 +1,9 @@ +Scripts in this directory were used when we cleaned up the global blobs table, +ensuring that it only contained global blobs. The scripts are meant to be run in this order: + +* `01-create-blob-hashes-table.sql` +* `02-set-global-flag.sql` +* `03-create-global-blobs-table.sql` +* `04-swap-global-blob-tables.sql` + +The `rollback.sql` can be run to reverse the effect of `03-swap-global-blob-tables.sql`. diff --git a/services/history-v1/storage/scripts/global-blobs-db-cleanup/rollback.sql b/services/history-v1/storage/scripts/global-blobs-db-cleanup/rollback.sql new file mode 100644 index 0000000000..c8d5e8f3e2 --- /dev/null +++ b/services/history-v1/storage/scripts/global-blobs-db-cleanup/rollback.sql @@ -0,0 +1,22 @@ +BEGIN; + ALTER TABLE blobs RENAME TO global_blobs; + ALTER TABLE old_blobs RENAME TO blobs; + + ALTER TABLE global_blobs + RENAME CONSTRAINT blobs_pkey TO global_blobs_pkey; + ALTER TABLE global_blobs + RENAME CONSTRAINT blobs_byte_length_non_negative + TO global_blobs_byte_length_non_negative; + ALTER TABLE global_blobs + RENAME CONSTRAINT blobs_string_length_non_negative + TO global_blobs_string_length_non_negative; + + ALTER TABLE blobs + RENAME CONSTRAINT old_blobs_pkey TO blobs_pkey; + ALTER TABLE blobs + RENAME CONSTRAINT old_blobs_byte_length_non_negative + TO blobs_byte_length_non_negative; + ALTER TABLE blobs + RENAME CONSTRAINT old_blobs_string_length_non_negative + TO blobs_string_length_non_negative; +COMMIT; diff --git a/services/history-v1/storage/tasks/backfill_start_version.js b/services/history-v1/storage/tasks/backfill_start_version.js new file mode 100644 index 0000000000..c79bcb5a79 --- /dev/null +++ b/services/history-v1/storage/tasks/backfill_start_version.js @@ -0,0 +1,109 @@ +const commandLineArgs = require('command-line-args') +const BPromise = require('bluebird') +const timersPromises = require('timers/promises') + +const { knex, historyStore } = require('..') + +const MAX_POSTGRES_INTEGER = 2147483647 +const DEFAULT_BATCH_SIZE = 1000 +const DEFAULT_CONCURRENCY = 1 +const MAX_RETRIES = 10 +const RETRY_DELAY_MS = 5000 + +async function main() { + const options = parseOptions() + let batchStart = options.minId + while (batchStart <= options.maxId) { + const chunks = await getChunks(batchStart, options.maxId, options.batchSize) + if (chunks.length === 0) { + // No results. We're done. + break + } + const batchEnd = chunks[chunks.length - 1].id + await processBatch(chunks, options) + console.log(`Processed chunks ${batchStart} to ${batchEnd}`) + batchStart = batchEnd + 1 + } +} + +function parseOptions() { + const args = commandLineArgs([ + { name: 'min-id', type: Number, defaultValue: 1 }, + { + name: 'max-id', + type: Number, + defaultValue: MAX_POSTGRES_INTEGER, + }, + { name: 'batch-size', type: Number, defaultValue: DEFAULT_BATCH_SIZE }, + { name: 'concurrency', type: Number, defaultValue: DEFAULT_CONCURRENCY }, + ]) + return { + minId: args['min-id'], + maxId: args['max-id'], + batchSize: args['batch-size'], + concurrency: args.concurrency, + } +} + +async function getChunks(minId, maxId, batchSize) { + const chunks = await knex('chunks') + .where('id', '>=', minId) + .andWhere('id', '<=', maxId) + .orderBy('id') + .limit(batchSize) + return chunks +} + +async function processBatch(chunks, options) { + let retries = 0 + while (true) { + const results = await BPromise.map(chunks, processChunk, { + concurrency: options.concurrency, + }) + const failedChunks = results + .filter(result => !result.success) + .map(result => result.chunk) + if (failedChunks.length === 0) { + // All chunks processed. Carry on. + break + } + + // Some projects failed. Retry. + retries += 1 + if (retries > MAX_RETRIES) { + console.log('Too many retries processing chunks. Giving up.') + process.exit(1) + } + console.log( + `Retrying chunks: ${failedChunks.map(chunk => chunk.id).join(', ')}` + ) + await timersPromises.setTimeout(RETRY_DELAY_MS) + chunks = failedChunks + } +} + +async function processChunk(chunk) { + try { + const rawHistory = await historyStore.loadRaw( + chunk.doc_id.toString(), + chunk.id + ) + const startVersion = chunk.end_version - rawHistory.changes.length + await knex('chunks') + .where('id', chunk.id) + .update({ start_version: startVersion }) + return { chunk, success: true } + } catch (err) { + console.error(`Failed to process chunk ${chunk.id}:`, err.stack) + return { chunk, success: false } + } +} + +main() + .then(() => { + process.exit() + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/history-v1/storage/tasks/compress_changes.js b/services/history-v1/storage/tasks/compress_changes.js new file mode 100644 index 0000000000..05f2132ad9 --- /dev/null +++ b/services/history-v1/storage/tasks/compress_changes.js @@ -0,0 +1,107 @@ +/** + * Compress changes for projects that have too many text operations. + * + * Usage: + * + * node tasks/compress_changes.js CSV_FILE + * + * where CSV_FILE contains a list of project ids in the first column + */ + +const fs = require('fs') +const BPromise = require('bluebird') +const { History } = require('overleaf-editor-core') +const { historyStore, chunkStore } = require('..') + +const CONCURRENCY = 10 + +async function main() { + const filename = process.argv[2] + const projectIds = await readCsv(filename) + const chunks = [] + for (const projectId of projectIds) { + const chunkIds = await chunkStore.getProjectChunkIds(projectId) + chunks.push(...chunkIds.map(id => ({ id, projectId }))) + } + let totalCompressed = 0 + await BPromise.map( + chunks, + async chunk => { + try { + const history = await getHistory(chunk) + const numCompressed = compressChanges(history) + if (numCompressed > 0) { + await storeHistory(chunk, history) + console.log( + `Compressed project ${chunk.projectId}, chunk ${chunk.id}` + ) + } + totalCompressed += numCompressed + } catch (err) { + console.log(err) + } + }, + { concurrency: CONCURRENCY } + ) + console.log('CHANGES:', totalCompressed) +} + +async function readCsv(filename) { + const csv = await fs.promises.readFile(filename, 'utf-8') + const lines = csv.trim().split('\n') + const projectIds = lines.map(line => line.split(',')[0]) + return projectIds +} + +async function getHistory(chunk) { + const rawHistory = await historyStore.loadRaw(chunk.projectId, chunk.id) + const history = History.fromRaw(rawHistory) + return history +} + +async function storeHistory(chunk, history) { + const rawHistory = history.toRaw() + await historyStore.storeRaw(chunk.projectId, chunk.id, rawHistory) +} + +function compressChanges(history) { + let numCompressed = 0 + for (const change of history.getChanges()) { + const newOperations = compressOperations(change.operations) + if (newOperations.length !== change.operations.length) { + numCompressed++ + } + change.setOperations(newOperations) + } + return numCompressed +} + +function compressOperations(operations) { + if (!operations.length) return [] + + const newOperations = [] + let currentOperation = operations[0] + for (let operationId = 1; operationId < operations.length; operationId++) { + const nextOperation = operations[operationId] + if (currentOperation.canBeComposedWith(nextOperation)) { + currentOperation = currentOperation.compose(nextOperation) + } else { + // currentOperation and nextOperation cannot be composed. Push the + // currentOperation and start over with nextOperation. + newOperations.push(currentOperation) + currentOperation = nextOperation + } + } + newOperations.push(currentOperation) + + return newOperations +} + +main() + .then(() => { + process.exit() + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/history-v1/storage/tasks/copy_project_blobs.js b/services/history-v1/storage/tasks/copy_project_blobs.js new file mode 100755 index 0000000000..6834fd7ac1 --- /dev/null +++ b/services/history-v1/storage/tasks/copy_project_blobs.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +const { promisify } = require('util') +const BPromise = require('bluebird') +const commandLineArgs = require('command-line-args') +const config = require('config') +const fs = require('fs') +const readline = require('readline') +const { History } = require('overleaf-editor-core') +const { knex, historyStore, persistor } = require('..') +const projectKey = require('../lib/project_key') + +const MAX_POSTGRES_INTEGER = 2147483647 +const DEFAULT_BATCH_SIZE = 1000 +const MAX_RETRIES = 10 +const RETRY_DELAY_MS = 5000 + +// Obtain a preconfigured GCS client through a non-documented property of +// object-persistor. Sorry about that. We need the GCS client because we use +// operations that are not implemented in object-persistor. +const gcsClient = persistor.storage +const globalBucket = gcsClient.bucket(config.get('blobStore.globalBucket')) +const projectBucket = gcsClient.bucket(config.get('blobStore.projectBucket')) +const delay = promisify(setTimeout) + +async function main() { + const options = commandLineArgs([ + { name: 'global-blobs', type: String }, + { name: 'min-project-id', type: Number, defaultValue: 1 }, + { + name: 'max-project-id', + type: Number, + defaultValue: MAX_POSTGRES_INTEGER, + }, + { name: 'batch-size', type: Number, defaultValue: DEFAULT_BATCH_SIZE }, + { name: 'concurrency', type: Number, defaultValue: 1 }, + ]) + if (!options['global-blobs']) { + console.error( + 'You must specify a global blobs file with the --global-blobs option' + ) + process.exit(1) + } + const globalBlobs = await readGlobalBlobs(options['global-blobs']) + const minProjectId = options['min-project-id'] + const maxProjectId = options['max-project-id'] + const batchSize = options['batch-size'] + const concurrency = options.concurrency + console.log(`Keeping ${globalBlobs.size} global blobs`) + await run({ globalBlobs, minProjectId, maxProjectId, batchSize, concurrency }) + console.log('Done.') +} + +async function readGlobalBlobs(filename) { + const stream = fs.createReadStream(filename) + const reader = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }) + const blobs = new Set() + for await (const line of reader) { + blobs.add(line.trim()) + } + return blobs +} + +async function run(options) { + const { globalBlobs, minProjectId, maxProjectId, batchSize, concurrency } = + options + let batchStart = minProjectId + while (batchStart <= maxProjectId) { + let projectIds = await getProjectIds(batchStart, maxProjectId, batchSize) + if (projectIds.length === 0) { + break + } + const batchEnd = projectIds[projectIds.length - 1] + console.log(`Processing projects ${batchStart} to ${batchEnd}`) + const chunkIdsByProject = await getChunkIdsByProject(projectIds) + + let retries = 0 + while (true) { + const results = await BPromise.map( + projectIds, + async projectId => + processProject( + projectId, + chunkIdsByProject.get(projectId), + globalBlobs + ), + { concurrency } + ) + const failedProjectIds = results + .filter(result => !result.success) + .map(result => result.projectId) + if (failedProjectIds.length === 0) { + // All projects were copied successfully. Carry on. + break + } + + // Some projects failed. Retry. + retries += 1 + if (retries > MAX_RETRIES) { + console.log( + `Too many retries processing projects ${batchStart} to ${batchEnd}. Giving up.` + ) + process.exit(1) + } + console.log(`Retrying projects: ${failedProjectIds.join(', ')}`) + await delay(RETRY_DELAY_MS) + projectIds = failedProjectIds + } + + // Set up next batch + batchStart = batchEnd + 1 + } +} + +async function getProjectIds(minProjectId, maxProjectId, batchSize) { + const projectIds = await knex('chunks') + .distinct('doc_id') + .where('doc_id', '>=', minProjectId) + .andWhere('doc_id', '<=', maxProjectId) + .orderBy('doc_id') + .limit(batchSize) + .pluck('doc_id') + return projectIds +} + +async function getChunkIdsByProject(projectIds) { + const chunks = await knex('chunks') + .select('id', { projectId: 'doc_id' }) + .where('doc_id', 'in', projectIds) + const chunkIdsByProject = new Map() + for (const projectId of projectIds) { + chunkIdsByProject.set(projectId, []) + } + for (const chunk of chunks) { + chunkIdsByProject.get(chunk.projectId).push(chunk.id) + } + return chunkIdsByProject +} + +async function processProject(projectId, chunkIds, globalBlobs) { + try { + const blobHashes = await getBlobHashes(projectId, chunkIds) + const projectBlobHashes = blobHashes.filter(hash => !globalBlobs.has(hash)) + const gcsSizesByHash = new Map() + for (const blobHash of projectBlobHashes) { + const blobSize = await copyBlobInGcs(projectId, blobHash) + if (blobSize != null) { + gcsSizesByHash.set(blobHash, blobSize) + } + } + const dbSizesByHash = await copyBlobsInDatabase( + projectId, + projectBlobHashes + ) + compareBlobSizes(gcsSizesByHash, dbSizesByHash) + return { projectId, success: true } + } catch (err) { + console.error(`Failed to process project ${projectId}:`, err.stack) + return { projectId, success: false } + } +} + +function compareBlobSizes(gcsSizesByHash, dbSizesByHash) { + // Throw an error if the database doesn't report as many blobs as GCS + if (dbSizesByHash.size !== gcsSizesByHash.size) { + throw new Error( + `the database reported ${dbSizesByHash.size} blobs copied, but GCS reported ${gcsSizesByHash.size} blobs copied` + ) + } + + const mismatches = [] + for (const [hash, dbSize] of dbSizesByHash.entries()) { + if (gcsSizesByHash.get(hash) !== dbSize) { + mismatches.push(hash) + } + } + if (mismatches.length > 0) { + throw new Error(`blob size mismatch for hashes: ${mismatches.join(', ')}`) + } +} + +async function getHistory(projectId, chunkId) { + const rawHistory = await historyStore.loadRaw(projectId, chunkId) + const history = History.fromRaw(rawHistory) + return history +} + +async function getBlobHashes(projectId, chunkIds) { + const blobHashes = new Set() + for (const chunkId of chunkIds) { + const history = await getHistory(projectId, chunkId) + history.findBlobHashes(blobHashes) + } + return Array.from(blobHashes) +} + +async function copyBlobInGcs(projectId, blobHash) { + const globalBlobKey = [ + blobHash.slice(0, 2), + blobHash.slice(2, 4), + blobHash.slice(4), + ].join('/') + const projectBlobKey = [ + projectKey.format(projectId), + blobHash.slice(0, 2), + blobHash.slice(2), + ].join('/') + const globalBlobObject = globalBucket.file(globalBlobKey) + const projectBlobObject = projectBucket.file(projectBlobKey) + + // Check if the project blob exists + let projectBlobMetadata = null + try { + ;[projectBlobMetadata] = await projectBlobObject.getMetadata() + } catch (err) { + if (err.code !== 404) { + throw err + } + } + + // Check that the blob exists + let globalBlobMetadata = null + try { + ;[globalBlobMetadata] = await globalBlobObject.getMetadata() + } catch (err) { + if (err.code !== 404) { + throw err + } + } + + if (projectBlobMetadata) { + // Project blob already exists. Compare the metadata if the global blob + // also exists and return early. + if ( + globalBlobMetadata != null && + (globalBlobMetadata.size !== projectBlobMetadata.size || + globalBlobMetadata.md5Hash !== projectBlobMetadata.md5Hash) + ) { + throw new Error( + `Project blob ${blobHash} in project ${projectId} doesn't match global blob` + ) + } + return null + } + + await globalBlobObject.copy(projectBlobObject) + + // Paranoid check that the copy went well. The getMetadata() method returns + // an array, with the metadata in first position. + ;[projectBlobMetadata] = await projectBlobObject.getMetadata() + if ( + globalBlobMetadata.size !== projectBlobMetadata.size || + globalBlobMetadata.md5Hash !== projectBlobMetadata.md5Hash + ) { + throw new Error(`Failed to copy blob ${blobHash} to project ${projectId})`) + } + + return parseInt(projectBlobMetadata.size, 10) +} + +async function copyBlobsInDatabase(projectId, blobHashes) { + const blobSizesByHash = new Map() + if (blobHashes.length === 0) { + return blobSizesByHash + } + const binaryBlobHashes = blobHashes.map(hash => Buffer.from(hash, 'hex')) + const result = await knex.raw( + `INSERT INTO project_blobs ( + project_id, hash_bytes, byte_length, string_length + ) + SELECT ?, hash_bytes, byte_length, string_length + FROM blobs + WHERE hash_bytes IN (${binaryBlobHashes.map(_ => '?').join(',')}) + ON CONFLICT (project_id, hash_bytes) DO NOTHING + RETURNING hash_bytes, byte_length`, + [projectId, ...binaryBlobHashes] + ) + for (const row of result.rows) { + blobSizesByHash.set(row.hash_bytes.toString('hex'), row.byte_length) + } + return blobSizesByHash +} + +main() + .then(() => { + process.exit() + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/history-v1/storage/tasks/count_blob_references.js b/services/history-v1/storage/tasks/count_blob_references.js new file mode 100755 index 0000000000..fce1579e96 --- /dev/null +++ b/services/history-v1/storage/tasks/count_blob_references.js @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +'use strict' + +/** + * This script fetches all history chunks from active projects (as listed in the + * active_doc_ids table) and counts how many times each blob is referenced. The + * reference count is stored in the blobs.estimated_reference_count column. + */ + +const Path = require('path') +const BPromise = require('bluebird') +const commandLineArgs = require('command-line-args') +const config = require('config') +const stringToStream = require('string-to-stream') + +const { History, EditFileOperation } = require('overleaf-editor-core') +const { knex, historyStore, persistor } = require('..') + +const DEFAULT_BATCH_SIZE = 100 +const DEFAULT_TIMEOUT = 23 * 60 * 60 // 23 hours +const MAX_POSTGRES_INTEGER = 2147483647 +const TEXT_OPERATION_COUNT_THRESHOLD = 500 +const BUCKET = config.get('analytics.bucket') +const BLOB_REFERENCE_COUNTS_PREFIX = 'blob-reference-counts/batches/' +const TEXT_OPERATION_COUNTS_PREFIX = 'text-operation-counts/' + +async function main() { + const programName = Path.basename(process.argv[1]) + const options = commandLineArgs([ + { name: 'restart', type: Boolean }, + { name: 'continue', type: Boolean }, + { name: 'batch-size', type: Number, defaultValue: DEFAULT_BATCH_SIZE }, + { name: 'timeout', type: Number, defaultValue: DEFAULT_TIMEOUT }, + { name: 'concurrency', type: Number, defaultValue: 1 }, + { name: 'min-doc-id', type: Number, defaultValue: 1 }, + { name: 'max-doc-id', type: Number, defaultValue: MAX_POSTGRES_INTEGER }, + ]) + const minDocId = options['min-doc-id'] + const maxDocId = options['max-doc-id'] + const runOptions = { + batchSize: options['batch-size'], + timeout: options.timeout, + concurrency: options.concurrency, + } + const inProgress = await isRunInProgress() + if (inProgress && !options.restart && !options.continue) { + console.log(`\ +A blob reference count is already under way. + +To resume this run, use: ${programName} --continue +To start a new run, use: ${programName} --restart`) + return + } + if (!inProgress || options.restart) { + await initialize() + } + const nextDocId = await getNextDocId(minDocId, maxDocId) + await run(nextDocId, maxDocId, runOptions) +} + +async function isRunInProgress() { + const record = await knex('blob_reference_count_batches').first() + return record != null +} + +async function getNextDocId(minDocId, maxDocId) { + const { lastDocId } = await knex('blob_reference_count_batches') + .where('end_doc_id', '<=', maxDocId) + .max({ lastDocId: 'end_doc_id' }) + .first() + if (lastDocId == null) { + return minDocId + } else { + return Math.max(minDocId, lastDocId + 1) + } +} + +async function initialize() { + await persistor.deleteDirectory(BUCKET, BLOB_REFERENCE_COUNTS_PREFIX) + await persistor.deleteDirectory(BUCKET, TEXT_OPERATION_COUNTS_PREFIX) + await knex('blob_reference_count_batches').truncate() +} + +async function run(startDocId, maxDocId, options) { + const { timeout, batchSize, concurrency } = options + const maxRunningTime = Date.now() + timeout * 1000 + let batchStart = startDocId + while (true) { + if (Date.now() > maxRunningTime) { + console.log('Timeout exceeded. Exiting early.') + break + } + const docIds = await getDocIds(batchStart, maxDocId, batchSize) + if (docIds.length === 0) { + console.log('No more projects to process. Bye!') + break + } + const batchEnd = docIds[docIds.length - 1] + console.log(`Processing doc ids ${batchStart} to ${batchEnd}...`) + const chunks = await getChunks(docIds) + const blobReferenceCounter = new BlobReferenceCounter() + const textOperationCounter = new TextOperationCounter() + await BPromise.map( + chunks, + async chunk => { + const history = await getHistory(chunk) + blobReferenceCounter.processHistory(history, chunk.projectId) + textOperationCounter.processHistory(history, chunk.projectId) + }, + { concurrency } + ) + await storeBlobReferenceCounts(batchStart, blobReferenceCounter.getCounts()) + await storeTextOperationCounts(batchStart, textOperationCounter.getCounts()) + await recordBatch(batchStart, batchEnd) + batchStart = batchEnd + 1 + } +} + +async function getDocIds(minDocId, maxDocId, batchSize) { + const docIds = await knex('active_doc_ids') + .select('doc_id') + .where('doc_id', '>=', minDocId) + .andWhere('doc_id', '<=', maxDocId) + .orderBy('doc_id') + .limit(batchSize) + .pluck('doc_id') + return docIds +} + +async function getChunks(docIds) { + const chunks = await knex('chunks') + .select('id', { projectId: 'doc_id' }) + .where('doc_id', 'in', docIds) + return chunks +} + +async function recordBatch(batchStart, batchEnd) { + await knex('blob_reference_count_batches').insert({ + start_doc_id: batchStart, + end_doc_id: batchEnd, + }) +} + +async function getHistory(chunk) { + const rawHistory = await historyStore.loadRaw(chunk.projectId, chunk.id) + const history = History.fromRaw(rawHistory) + return history +} + +async function storeBlobReferenceCounts(startDocId, counts) { + const key = `${BLOB_REFERENCE_COUNTS_PREFIX}${startDocId}.csv` + const csv = makeCsvFromMap(counts) + const stream = stringToStream(csv) + persistor.sendStream(BUCKET, key, stream) +} + +async function storeTextOperationCounts(startDocId, counts) { + const key = `${TEXT_OPERATION_COUNTS_PREFIX}${startDocId}.csv` + const csv = makeCsvFromMap(counts) + const stream = stringToStream(csv) + await persistor.sendStream(BUCKET, key, stream) +} + +function makeCsvFromMap(map) { + const entries = Array.from(map.entries()) + entries.sort((a, b) => { + if (a[0] < b[0]) { + return -1 + } + if (a[0] > b[0]) { + return 1 + } + return 0 + }) + return entries.map(entry => entry.join(',')).join('\n') +} + +function incrementMapEntry(map, key) { + const currentCount = map.get(key) || 0 + map.set(key, currentCount + 1) +} + +class BlobReferenceCounter { + constructor() { + this.blobHashesByProjectId = new Map() + } + + processHistory(history, projectId) { + let blobHashes = this.blobHashesByProjectId.get(projectId) + if (blobHashes == null) { + blobHashes = new Set() + this.blobHashesByProjectId.set(projectId, blobHashes) + } + history.findBlobHashes(blobHashes) + } + + getCounts() { + const countsByHash = new Map() + for (const blobHashes of this.blobHashesByProjectId.values()) { + for (const hash of blobHashes) { + incrementMapEntry(countsByHash, hash) + } + } + return countsByHash + } +} + +class TextOperationCounter { + constructor() { + this.countsByProjectId = new Map() + } + + processHistory(history, projectId) { + for (const change of history.getChanges()) { + let textOperationCount = 0 + for (const operation of change.getOperations()) { + if (operation instanceof EditFileOperation) { + textOperationCount++ + } + } + if (textOperationCount >= TEXT_OPERATION_COUNT_THRESHOLD) { + this.countsByProjectId.set( + projectId, + Math.max( + this.countsByProjectId.get(projectId) || 0, + textOperationCount + ) + ) + } + } + } + + getCounts() { + return this.countsByProjectId + } +} + +main() + .then(() => { + process.exit() + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/history-v1/storage/tasks/delete_old_chunks.js b/services/history-v1/storage/tasks/delete_old_chunks.js new file mode 100644 index 0000000000..e1201110fe --- /dev/null +++ b/services/history-v1/storage/tasks/delete_old_chunks.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +'use strict' + +const commandLineArgs = require('command-line-args') +const { chunkStore } = require('../') + +async function deleteOldChunks(options) { + const deletedChunksTotal = await chunkStore.deleteOldChunks(options) + console.log(`Deleted ${deletedChunksTotal} old chunks`) +} + +exports.deleteOldChunks = deleteOldChunks + +if (require.main === module) { + const options = commandLineArgs([ + { name: 'batch-size', type: Number }, + { name: 'max-batches', type: Number }, + { name: 'min-age', type: Number }, + { name: 'timeout', type: Number }, + { name: 'verbose', type: Boolean, alias: 'v', defaultValue: false }, + ]) + deleteOldChunks({ + batchSize: options['batch-size'], + maxBatches: options['max-batches'], + timeout: options.timeout, + minAgeSecs: options['min-age'], + }) + .then(() => { + process.exit() + }) + .catch(err => { + console.error(err) + process.exit(1) + }) +} diff --git a/services/history-v1/storage/tasks/fix_duplicate_versions.js b/services/history-v1/storage/tasks/fix_duplicate_versions.js new file mode 100755 index 0000000000..a7db4b2765 --- /dev/null +++ b/services/history-v1/storage/tasks/fix_duplicate_versions.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +'use strict' + +const commandLineArgs = require('command-line-args') +const { chunkStore } = require('..') + +main() + .then(() => { + process.exit(0) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) + +async function main() { + const opts = commandLineArgs([ + { name: 'project-ids', type: String, multiple: true, defaultOption: true }, + { name: 'save', type: Boolean, defaultValue: false }, + { name: 'help', type: Boolean, defaultValue: false }, + ]) + if (opts.help || opts['project-ids'] == null) { + console.log('Usage: fix_duplicate_versions [--save] PROJECT_ID...') + process.exit() + } + for (const projectId of opts['project-ids']) { + await processProject(projectId, opts.save) + } + if (!opts.save) { + console.log('\nThis was a dry run. Re-run with --save to persist changes.') + } +} + +async function processProject(projectId, save) { + console.log(`Project ${projectId}:`) + const chunk = await chunkStore.loadLatest(projectId) + let numChanges = 0 + numChanges += removeDuplicateProjectVersions(chunk) + numChanges += removeDuplicateDocVersions(chunk) + console.log(` ${numChanges > 0 ? numChanges : 'no'} changes`) + if (save && numChanges > 0) { + await replaceChunk(projectId, chunk) + } +} + +function removeDuplicateProjectVersions(chunk) { + let numChanges = 0 + let lastVersion = null + const { snapshot, changes } = chunk.history + if (snapshot.projectVersion != null) { + lastVersion = snapshot.projectVersion + } + for (const change of changes) { + if (change.projectVersion == null) { + // Not a project structure change. Ignore. + continue + } + if ( + lastVersion != null && + !areProjectVersionsIncreasing(lastVersion, change.projectVersion) + ) { + // Duplicate. Remove all ops + console.log( + ` Removing out-of-order project structure change: ${change.projectVersion} <= ${lastVersion}` + ) + change.setOperations([]) + delete change.projectVersion + numChanges++ + } else { + lastVersion = change.projectVersion + } + } + + return numChanges +} + +function removeDuplicateDocVersions(chunk) { + let numChanges = 0 + const lastVersions = new Map() + const { snapshot, changes } = chunk.history + if (snapshot.v2DocVersions != null) { + for (const { pathname, v } of Object.values(snapshot.v2DocVersions.data)) { + lastVersions.set(pathname, v) + } + } + for (const change of changes) { + if (change.v2DocVersions == null) { + continue + } + + // Collect all docs that have problematic versions + const badPaths = [] + const badDocIds = [] + for (const [docId, { pathname, v }] of Object.entries( + change.v2DocVersions.data + )) { + const lastVersion = lastVersions.get(docId) + if (lastVersion != null && v <= lastVersion) { + // Duplicate. Remove ops related to that doc + console.log( + ` Removing out-of-order change for doc ${docId} (${pathname}): ${v} <= ${lastVersion}` + ) + badPaths.push(pathname) + badDocIds.push(docId) + numChanges++ + } else { + lastVersions.set(docId, v) + } + } + + // Remove bad operations + if (badPaths.length > 0) { + change.setOperations( + change.operations.filter( + op => op.pathname == null || !badPaths.includes(op.pathname) + ) + ) + } + + // Remove bad v2 doc versions + for (const docId of badDocIds) { + delete change.v2DocVersions.data[docId] + } + } + + return numChanges +} + +function areProjectVersionsIncreasing(v1Str, v2Str) { + const v1 = parseProjectVersion(v1Str) + const v2 = parseProjectVersion(v2Str) + return v2.major > v1.major || (v2.major === v1.major && v2.minor > v1.minor) +} + +function parseProjectVersion(version) { + const [major, minor] = version.split('.').map(x => parseInt(x, 10)) + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid project version: ${version}`) + } + return { major, minor } +} + +async function replaceChunk(projectId, chunk) { + const endVersion = chunk.getEndVersion() + const oldChunkId = await chunkStore.getChunkIdForVersion( + projectId, + endVersion + ) + console.log(` Replacing chunk ${oldChunkId}`) + // The chunks table has a unique constraint on doc_id and end_version. Because + // we're replacing a chunk with the same end version, we need to destroy the + // chunk first. + await chunkStore.destroy(projectId, oldChunkId) + await chunkStore.create(projectId, chunk) +} diff --git a/services/history-v1/storage/tasks/index.js b/services/history-v1/storage/tasks/index.js new file mode 100644 index 0000000000..65bed63682 --- /dev/null +++ b/services/history-v1/storage/tasks/index.js @@ -0,0 +1 @@ +exports.deleteOldChunks = require('./delete_old_chunks').deleteOldChunks diff --git a/services/history-v1/test/acceptance/js/api/auth.test.js b/services/history-v1/test/acceptance/js/api/auth.test.js new file mode 100644 index 0000000000..65c9219ae2 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/auth.test.js @@ -0,0 +1,195 @@ +const config = require('config') +const fetch = require('node-fetch') +const sinon = require('sinon') +const { expect } = require('chai') + +const cleanup = require('../storage/support/cleanup') +const expectResponse = require('./support/expect_response') +const fixtures = require('../storage/support/fixtures') +const HTTPStatus = require('http-status') +const testServer = require('./support/test_server') + +describe('auth', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + beforeEach('Set up stubs', function () { + sinon.stub(config, 'has').callThrough() + sinon.stub(config, 'get').callThrough() + }) + afterEach(sinon.restore) + + it('renders 401 on swagger docs endpoint without auth', async function () { + const response = await fetch(testServer.url('/docs')) + expect(response.status).to.equal(HTTPStatus.UNAUTHORIZED) + expect(response.headers.get('www-authenticate')).to.match(/^Basic/) + }) + + it('renders swagger docs endpoint with auth', async function () { + const response = await fetch(testServer.url('/docs'), { + headers: { + Authorization: testServer.basicAuthHeader, + }, + }) + expect(response.ok).to.be.true + }) + + it('takes an old basic auth password during a password change', async function () { + setMockConfig('basicHttpAuth.oldPassword', 'foo') + + // Primary should still work. + const response1 = await fetch(testServer.url('/docs'), { + headers: { + Authorization: testServer.basicAuthHeader, + }, + }) + expect(response1.ok).to.be.true + + // Old password should also work. + const response2 = await fetch(testServer.url('/docs'), { + headers: { + Authorization: 'Basic ' + Buffer.from('staging:foo').toString('base64'), + }, + }) + expect(response2.ok).to.be.true + + // Incorrect password should not work. + const response3 = await fetch(testServer.url('/docs'), { + header: { + Authorization: 'Basic ' + Buffer.from('staging:bar').toString('base64'), + }, + }) + expect(response3.status).to.equal(HTTPStatus.UNAUTHORIZED) + }) + + it('renders 401 on ProjectImport endpoints', async function () { + const unauthenticatedClient = testServer.client + try { + await unauthenticatedClient.apis.ProjectImport.importSnapshot1({ + project_id: '1', + snapshot: { files: {} }, + }) + expect.fail() + } catch (err) { + expectResponse.unauthorized(err) + expect(err.response.headers['www-authenticate']).to.match(/^Basic/) + } + + // check that the snapshot was not persisted even if the response was a 401 + const projectClient = await testServer.createClientForProject('1') + try { + await projectClient.apis.Project.getLatestHistory({ project_id: '1' }) + expect.fail() + } catch (err) { + expectResponse.notFound(err) + } + }) + + it('renders 401 for JWT endpoints', function () { + return testServer.client.apis.Project.getLatestHistory({ + project_id: '10000', + }) + .then(() => { + expect.fail() + }) + .catch(err => { + expectResponse.unauthorized(err) + expect(err.response.headers['www-authenticate']).to.equal('Bearer') + }) + }) + + it('accepts basic auth in place of JWT (for now)', function () { + const projectId = fixtures.docs.initializedProject.id + return testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({ + project_id: projectId, + }).then(response => { + expect(response.obj.chunk).to.exist + }) + }) + + it('uses JWT', function () { + const projectId = fixtures.docs.initializedProject.id + return testServer + .createClientForProject(projectId) + .then(client => { + return client.apis.Project.getLatestHistory({ + project_id: projectId, + }) + }) + .then(response => { + expect(response.obj.chunk).to.exist + }) + }) + + it('checks for project id', function () { + return testServer + .createClientForProject('1') + .then(client => { + return client.apis.Project.getLatestHistory({ + project_id: '2', + }) + }) + .then(() => { + expect.fail() + }) + .catch(expectResponse.forbidden) + }) + + it('does not accept jwt for ProjectUpdate endpoints', function () { + return testServer.createClientForProject('1').then(client => { + return client.apis.ProjectImport.importSnapshot1({ + project_id: '1', + snapshot: {}, + }) + .then(() => { + expect.fail() + }) + .catch(expectResponse.unauthorized) + }) + }) + + describe('when an old JWT key is defined', function () { + beforeEach(function () { + setMockConfig('jwtAuth.oldKey', 'old-secret') + }) + + it('accepts the old key', async function () { + const projectId = fixtures.docs.initializedProject.id + const client = await testServer.createClientForProject(projectId, { + jwtKey: 'old-secret', + }) + const response = await client.apis.Project.getLatestHistory({ + project_id: projectId, + }) + expect(response.obj.chunk).to.exist + }) + + it('accepts the new key', async function () { + const projectId = fixtures.docs.initializedProject.id + const client = await testServer.createClientForProject(projectId) + const response = await client.apis.Project.getLatestHistory({ + project_id: projectId, + }) + expect(response.obj.chunk).to.exist + }) + + it('rejects other keys', async function () { + const projectId = fixtures.docs.initializedProject.id + const client = await testServer.createClientForProject(projectId, { + jwtKey: 'bad-secret', + }) + try { + await client.apis.Project.getLatestHistory({ + project_id: projectId, + }) + expect.fail() + } catch (err) { + expectResponse.unauthorized(err) + } + }) + }) +}) + +function setMockConfig(path, value) { + config.has.withArgs(path).returns(true) + config.get.withArgs(path).returns(value) +} diff --git a/services/history-v1/test/acceptance/js/api/end_to_end.test.js b/services/history-v1/test/acceptance/js/api/end_to_end.test.js new file mode 100644 index 0000000000..89c178d5f5 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/end_to_end.test.js @@ -0,0 +1,391 @@ +'use strict' + +const BPromise = require('bluebird') +const { expect } = require('chai') +const HTTPStatus = require('http-status') +const fetch = require('node-fetch') +const fs = BPromise.promisifyAll(require('fs')) + +const cleanup = require('../storage/support/cleanup') +const fixtures = require('../storage/support/fixtures') +const testFiles = require('../storage/support/test_files') +const testProjects = require('./support/test_projects') +const testServer = require('./support/test_server') + +const core = require('overleaf-editor-core') +const Change = core.Change +const ChunkResponse = core.ChunkResponse +const File = core.File +const Operation = core.Operation +const Snapshot = core.Snapshot +const TextOperation = core.TextOperation + +const blobHash = require('../../../../storage').blobHash + +describe('overleaf ot', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + this.timeout(10000) // it takes a while on Docker for Mac + + it('can use API', function () { + let client, downloadZipClient + + const basicAuthClient = testServer.basicAuthClient + return ( + testProjects + .createEmptyProject() + .then(projectId => { + return testServer + .createClientForProject(projectId) + .then(clientForProject => { + client = clientForProject + return testServer.createClientForDownloadZip(projectId) + }) + .then(clientForProject => { + downloadZipClient = clientForProject + return projectId + }) + }) + + // the project is currently empty + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(0) + return projectId + }) + }) + + // upload a blob and add two files using it + .then(projectId => { + return fetch( + testServer.url( + `/api/projects/${projectId}/blobs/${testFiles.GRAPH_PNG_HASH}`, + { qs: { pathname: 'graph_1.png' } } + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('graph.png')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + const testFile = File.fromHash(testFiles.GRAPH_PNG_HASH) + + const change = new Change( + [ + Operation.addFile('graph_1.png', testFile), + Operation.addFile('graph_2.png', testFile), + ], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + end_version: 0, + return_snapshot: 'hashed', + changes: [change.toRaw()], + }) + }) + .then(() => projectId) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + const file0 = snapshot.getFile('graph_1.png') + expect(file0.getHash()).to.equal(testFiles.GRAPH_PNG_HASH) + const file1 = snapshot.getFile('graph_2.png') + expect(file1.getHash()).to.equal(testFiles.GRAPH_PNG_HASH) + return projectId + }) + }) + + // get the history + .then(projectId => { + return client.apis.Project.getLatestHistory({ + project_id: projectId, + }).then(response => { + const chunk = ChunkResponse.fromRaw(response.obj).getChunk() + const changes = chunk.getChanges() + expect(changes.length).to.equal(1) + const change0Timestamp = changes[0].getTimestamp().getTime() + expect(change0Timestamp).to.be.closeTo(Date.now(), 1e4) + return projectId + }) + }) + + // upload an empty file + .then(projectId => { + return fetch( + testServer.url( + `/api/projects/${projectId}/blobs/${File.EMPTY_FILE_HASH}`, + { qs: { pathname: 'main.tex' } } + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + + const change = new Change( + [Operation.addFile('main.tex', testFile)], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + end_version: 1, + return_snapshot: 'hashed', + changes: [change.toRaw()], + }) + }) + .then(() => projectId) + }) + + .tap(projectId => { + // Fetch empty file blob + return client.apis.Project.getProjectBlob({ + project_id: projectId, + hash: File.EMPTY_FILE_HASH, + }) + .then(response => { + expect(response.headers['content-type']).to.equal( + 'application/octet-stream' + ) + return response.data.arrayBuffer() + }) + .then(buffer => { + expect(buffer).to.deep.equal(new ArrayBuffer(0)) + return projectId + }) + }) + + // get the history + .then(projectId => { + return client.apis.Project.getLatestHistory({ + project_id: projectId, + }).then(response => { + const chunk = ChunkResponse.fromRaw(response.obj).getChunk() + const changes = chunk.getChanges() + expect(changes.length).to.equal(2) + return projectId + }) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('graph_1.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + expect(snapshot.getFile('graph_2.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + expect(snapshot.getFile('main.tex').getContent()).to.equal('') + return projectId + }) + }) + + // edit the main file + .then(projectId => { + const change = new Change( + [Operation.editFile('main.tex', TextOperation.fromJSON(['hello']))], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [change.toRaw()], + end_version: 2, + return_snapshot: 'hashed', + }).then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('graph_1.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + expect(snapshot.getFile('graph_2.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + expect(snapshot.getFile('main.tex').getHash()).to.equal( + blobHash.fromString('hello') + ) + return projectId + }) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('graph_1.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + expect(snapshot.getFile('graph_2.png').getHash()).to.equal( + testFiles.GRAPH_PNG_HASH + ) + const mainFile = snapshot.getFile('main.tex') + expect(mainFile.getHash()).to.be.null + expect(mainFile.getContent()).to.equal('hello') + return projectId + }) + }) + + // edit the main file again + .then(projectId => { + const change = new Change( + [ + Operation.editFile( + 'main.tex', + TextOperation.fromJSON([1, -4, 'i world']) + ), + ], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [change.toRaw()], + end_version: 3, + return_snapshot: 'hashed', + }).then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('main.tex').getHash()).to.equal( + blobHash.fromString('hi world') + ) + return projectId + }) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('graph_1.png')).to.exist + expect(snapshot.getFile('graph_2.png')).to.exist + const mainFile = snapshot.getFile('main.tex') + expect(mainFile.getHash()).to.be.null + expect(mainFile.getContent()).to.equal('hi world') + return projectId + }) + }) + + // rename the text file + .then(projectId => { + const change = new Change( + [Operation.moveFile('main.tex', 'intro.tex')], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [change.toRaw()], + end_version: 4, + return_snapshot: 'hashed', + }).then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('intro.tex').getHash()).to.equal( + blobHash.fromString('hi world') + ) + return projectId + }) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(3) + expect(snapshot.getFile('graph_1.png')).to.exist + expect(snapshot.getFile('graph_2.png')).to.exist + const mainFile = snapshot.getFile('intro.tex') + expect(mainFile.getHash()).to.be.null + expect(mainFile.getContent()).to.equal('hi world') + return projectId + }) + }) + + // remove a graph + .then(projectId => { + const change = new Change( + [Operation.removeFile('graph_1.png')], + new Date() + ) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [change.toRaw()], + end_version: 5, + return_snapshot: 'hashed', + }).then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + return projectId + }) + }) + + // get the new project state + .then(projectId => { + return client.apis.Project.getLatestContent({ + project_id: projectId, + }).then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + expect(snapshot.getFile('graph_2.png')).to.exist + const mainFile = snapshot.getFile('intro.tex') + expect(mainFile.getHash()).to.be.null + expect(mainFile.getContent()).to.equal('hi world') + return projectId + }) + }) + + // download zip with project content + .then(projectId => { + return downloadZipClient.apis.Project.getZip({ + project_id: projectId, + version: 6, + }).then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + const headers = response.headers + expect(headers['content-type']).to.equal('application/octet-stream') + expect(headers['content-disposition']).to.equal( + 'attachment; filename=project.zip' + ) + }) + }) + ) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/project_blobs.test.js b/services/history-v1/test/acceptance/js/api/project_blobs.test.js new file mode 100644 index 0000000000..acf4a349c7 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/project_blobs.test.js @@ -0,0 +1,123 @@ +const { expect } = require('chai') +const config = require('config') +const fs = require('fs') +const fetch = require('node-fetch') +const HTTPStatus = require('http-status') + +const cleanup = require('../storage/support/cleanup') +const fixtures = require('../storage/support/fixtures') +const testFiles = require('../storage/support/test_files') +const testServer = require('./support/test_server') +const { expectHttpError } = require('./support/expect_response') + +describe('Project blobs API', function () { + const projectId = '123' + + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + let client + let token + before(async function () { + client = await testServer.createClientForProject(projectId) + token = testServer.createTokenForProject(projectId) + }) + + it('returns 404 if the blob is not found', async function () { + const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + await expectHttpError( + client.apis.Project.getProjectBlob({ + project_id: projectId, + hash: testHash, + }), + HTTPStatus.NOT_FOUND + ) + }) + + it('checks if file hash matches the hash parameter', async function () { + const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const response = await fetch( + testServer.url(`/api/projects/${projectId}/blobs/${testHash}`), + { + method: 'PUT', + headers: { Authorization: `Bearer ${token}` }, + body: fs.createReadStream(testFiles.path('hello.txt')), + } + ) + expect(response.status).to.equal(HTTPStatus.CONFLICT) + + // check that it did not store the file + await expectHttpError( + client.apis.Project.getProjectBlob({ + project_id: projectId, + hash: testFiles.HELLO_TXT_HASH, + }), + HTTPStatus.NOT_FOUND + ) + }) + + it('rejects oversized files', async function () { + const testHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const buffer = Buffer.alloc( + parseInt(config.get('maxFileUploadSize'), 10) + 1 + ) + const response = await fetch( + testServer.url(`/api/projects/${projectId}/blobs/${testHash}`), + { + method: 'PUT', + headers: { Authorization: `Bearer ${token}` }, + body: buffer, + } + ) + expect(response.status).to.equal(HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + }) + + describe('with an existing blob', async function () { + let fileContents + + beforeEach(async function () { + fileContents = await fs.promises.readFile(testFiles.path('hello.txt')) + const response = await fetch( + testServer.url( + `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}` + ), + { + method: 'PUT', + headers: { Authorization: `Bearer ${token}` }, + body: fileContents, + } + ) + expect(response.ok).to.be.true + }) + + it('fulfills a request with a JWT header', async function () { + const response = await client.apis.Project.getProjectBlob({ + project_id: projectId, + hash: testFiles.HELLO_TXT_HASH, + }) + const responseText = await response.data.text() + expect(responseText).to.equal(fileContents.toString()) + }) + + it('fulfills a request with a token parameter', async function () { + const url = new URL( + testServer.url( + `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}` + ) + ) + url.searchParams.append('token', token) + const response = await fetch(url) + const payload = await response.text() + expect(payload).to.equal(fileContents.toString()) + }) + + it('rejects an unautorized request', async function () { + const response = await fetch( + testServer.url( + `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}` + ) + ) + expect(response.status).to.equal(HTTPStatus.UNAUTHORIZED) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/project_import.test.js b/services/history-v1/test/acceptance/js/api/project_import.test.js new file mode 100644 index 0000000000..3da3b5f8c2 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/project_import.test.js @@ -0,0 +1,57 @@ +'use strict' + +const BPromise = require('bluebird') +const { expect } = require('chai') +const HTTPStatus = require('http-status') +const fetch = require('node-fetch') +const fs = BPromise.promisifyAll(require('fs')) + +const cleanup = require('../storage/support/cleanup') +const fixtures = require('../storage/support/fixtures') +const testFiles = require('../storage/support/test_files') +const testProjects = require('./support/test_projects') +const testServer = require('./support/test_server') + +const { Change, File, Operation } = require('overleaf-editor-core') + +describe('project import', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + it('skips generating the snapshot by default', async function () { + const basicAuthClient = testServer.basicAuthClient + const projectId = await testProjects.createEmptyProject() + + // upload an empty file + const response = await fetch( + testServer.url( + `/api/projects/${projectId}/blobs/${File.EMPTY_FILE_HASH}`, + { qs: { pathname: 'main.tex' } } + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + expect(response.ok).to.be.true + + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const testChange = new Change( + [Operation.addFile('main.tex', testFile)], + new Date() + ) + + const importResponse = + await basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + end_version: 0, + changes: [testChange.toRaw()], + }) + + expect(importResponse.status).to.equal(HTTPStatus.CREATED) + expect(importResponse.obj).to.deep.equal({}) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/project_updates.test.js b/services/history-v1/test/acceptance/js/api/project_updates.test.js new file mode 100644 index 0000000000..0a4c8dc6db --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/project_updates.test.js @@ -0,0 +1,850 @@ +const BPromise = require('bluebird') +const { expect } = require('chai') +const fs = BPromise.promisifyAll(require('fs')) +const HTTPStatus = require('http-status') +const fetch = require('node-fetch') + +const cleanup = require('../storage/support/cleanup') +const fixtures = require('../storage/support/fixtures') +const testFiles = require('../storage/support/test_files') +const expectResponse = require('./support/expect_response') +const testServer = require('./support/test_server') + +const core = require('overleaf-editor-core') +const Change = core.Change +const ChunkResponse = core.ChunkResponse +const File = core.File +const Operation = core.Operation +const Origin = core.Origin +const Snapshot = core.Snapshot +const TextOperation = core.TextOperation +const V2DocVersions = core.V2DocVersions + +const knex = require('../../../../storage').knex + +describe('history import', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + function changeToRaw(change) { + return change.toRaw() + } + + function makeChange(operation) { + return new Change([operation], new Date(), []) + } + + let basicAuthClient + let pseudoJwtBasicAuthClient + let clientForProject + + before(async function () { + basicAuthClient = testServer.basicAuthClient + pseudoJwtBasicAuthClient = testServer.pseudoJwtBasicAuthClient + clientForProject = await testServer.createClientForProject('1') + }) + + it('creates blobs and then imports a snapshot and history', function () { + // We need to be able to set the projectId to match an existing doc ID. + const testProjectId = '1' + const testFilePathname = 'main.tex' + const testAuthors = [123, null] + const testTextOperation0 = TextOperation.fromJSON(['a']) + const testTextOperation1 = TextOperation.fromJSON([1, 'b']) + + let testSnapshot + + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import project + testSnapshot = new Snapshot() + testSnapshot.addFile( + testFilePathname, + File.fromHash(File.EMPTY_FILE_HASH) + ) + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(response => { + // Check project is valid + expect(response.obj.projectId).to.equal(testProjectId) + }) + .then(() => { + // Try importing the project again + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(() => { + // Check that importing a duplicate fails + expect.fail() + }) + .catch(expectResponse.conflict) + .then(() => { + // Get project history + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that the imported history is valid + const chunk = ChunkResponse.fromRaw(response.obj).getChunk() + const snapshot = chunk.getSnapshot() + expect(snapshot.countFiles()).to.equal(1) + const file = snapshot.getFile(testFilePathname) + expect(file.getHash()).to.eql(File.EMPTY_FILE_HASH) + expect(chunk.getChanges().length).to.equal(0) + expect(chunk.getEndVersion()).to.equal(0) + }) + .then(() => { + // Import changes with an end version + const changes = [ + makeChange(Operation.editFile(testFilePathname, testTextOperation0)), + makeChange(Operation.editFile(testFilePathname, testTextOperation1)), + ] + changes[0].setAuthors(testAuthors) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + changes: changes.map(changeToRaw), + end_version: 0, + return_snapshot: 'hashed', + }) + }) + .then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile('main.tex').getHash()).to.equal( + testFiles.STRING_AB_HASH + ) + }) + + .then(() => { + // Get project history + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that the history is valid + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const chunk = chunkResponse.getChunk() + const snapshot = chunk.getSnapshot() + expect(snapshot.countFiles()).to.equal(1) + const file = snapshot.getFile(testFilePathname) + expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH) + expect(chunk.getChanges().length).to.equal(2) + const changeWithAuthors = chunk.getChanges()[0] + expect(changeWithAuthors.getAuthors().length).to.equal(2) + expect(changeWithAuthors.getAuthors()).to.deep.equal(testAuthors) + expect(chunk.getStartVersion()).to.equal(0) + expect(chunk.getEndVersion()).to.equal(2) + }) + .then(() => { + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // it should retrieve the same chunk + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const chunk = chunkResponse.getChunk() + expect(chunk.getChanges().length).to.equal(2) + expect(chunk.getStartVersion()).to.equal(0) + expect(chunk.getEndVersion()).to.equal(2) + }) + .then(() => { + // Get project's latest content + return clientForProject.apis.Project.getLatestContent({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that the content is valid + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + const file = snapshot.getFile(testFilePathname) + expect(file.getContent()).to.equal('ab') + }) + }) + + it('rejects invalid changes in history', function () { + const testProjectId = '1' + const testFilePathname = 'main.tex' + const testTextOperation = TextOperation.fromJSON(['a', 10]) + + let testSnapshot + + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import project + testSnapshot = new Snapshot() + testSnapshot.addFile( + testFilePathname, + File.fromHash(File.EMPTY_FILE_HASH) + ) + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(response => { + // Check project is valid + expect(response.obj.projectId).to.equal(testProjectId) + }) + .then(() => { + // Import invalid changes + const changes = [ + makeChange(Operation.editFile(testFilePathname, testTextOperation)), + ] + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + return_snapshot: 'hashed', + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Check that this fails + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + .then(() => { + // Get the latest content + return clientForProject.apis.Project.getLatestContent({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that no changes have been stored + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + const file = snapshot.getFile(testFilePathname) + expect(file.getContent()).to.equal('') + }) + .then(() => { + // Send a change with the wrong end version that is not conflicting + // with the latest snapshot + const changes = [makeChange(Operation.removeFile(testFilePathname))] + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 10000, + changes, + }) + }) + .then(() => { + // Check that this fails + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + .then(() => { + // Get the latest project history + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that no changes have been stored + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const changes = chunkResponse.getChunk().getChanges() + expect(changes).to.have.length(0) + }) + }) + + it('creates and edits a file using changes', function () { + const testProjectId = '1' + const mainFilePathname = 'main.tex' + const testFilePathname = 'test.tex' + const testTextOperation = TextOperation.fromJSON(['a']) + const inexistentAuthors = [1234, 5678] + const projectVersion = '12345.0' + const v2DocVersions = new V2DocVersions({ + 'random-doc-id': { pathname: 'doc-path.tex', v: 123 }, + }) + const testLabelOrigin = Origin.fromRaw({ + kind: 'saved ver', + }) + const testRestoreOrigin = Origin.fromRaw({ + kind: 'restore', + timestamp: '2016-01-01T00:00:00', + version: 1, + }) + + let testSnapshot + + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import a project + testSnapshot = new Snapshot() + testSnapshot.addFile( + mainFilePathname, + File.fromHash(File.EMPTY_FILE_HASH) + ) + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(response => { + // Check that the project is valid + expect(response.obj.projectId).to.equal(testProjectId) + }) + .then(() => { + // Import changes + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [ + makeChange(Operation.addFile(testFilePathname, testFile)), + makeChange(Operation.editFile(testFilePathname, testTextOperation)), + ] + changes[0].setProjectVersion(projectVersion) + changes[1].setAuthors(inexistentAuthors) + changes[1].setV2DocVersions(v2DocVersions) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + return_snapshot: 'hashed', + changes: changes.map(changeToRaw), + }) + }) + .then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + expect(snapshot.getFile('main.tex').getHash()).to.equal( + File.EMPTY_FILE_HASH + ) + expect(snapshot.getFile('test.tex').getHash()).to.equal( + testFiles.STRING_A_HASH + ) + }) + .then(() => { + // Get the project history + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // it should not fail when the some of the authors do not exist anymore + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const changes = chunkResponse.getChunk().getChanges() + expect(changes.length).to.equal(2) + const changeWithAuthor = changes[1] + expect(changeWithAuthor.getAuthors()).to.deep.equal(inexistentAuthors) + }) + .then(() => { + // it should retrieve the latest snapshot when the changes set is empty + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + return_snapshot: 'hashed', + changes: [], + }) + }) + .then(response => { + // Check latest snapshot + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + expect(snapshot.getFile('main.tex').getHash()).to.equal( + File.EMPTY_FILE_HASH + ) + expect(snapshot.getFile('test.tex').getHash()).to.equal( + testFiles.STRING_A_HASH + ) + expect(snapshot.getProjectVersion()).to.equal(projectVersion) + expect(snapshot.getV2DocVersions()).to.deep.equal(v2DocVersions) + }) + .then(() => { + // Import changes with origin + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [ + makeChange(Operation.removeFile(testFilePathname)), + makeChange(Operation.addFile(testFilePathname, testFile)), + ] + changes[0].setOrigin(testLabelOrigin) + changes[1].setOrigin(testRestoreOrigin) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Get the latest history + return clientForProject.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that the origin is stored + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const changes = chunkResponse.getChunk().getChanges() + expect(changes).to.have.length(4) + expect(changes[2].getOrigin()).to.deep.equal(testLabelOrigin) + expect(changes[3].getOrigin()).to.deep.equal(testRestoreOrigin) + }) + .then(() => { + // Import invalid changes + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [makeChange(Operation.addFile('../../a.tex', testFile))] + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Check that this fails and returns a 422 + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + }) + + it('rejects text operations on binary files', function () { + const testProjectId = '1' + const testFilePathname = 'main.tex' + const testTextOperation = TextOperation.fromJSON(['bb']) + + let testSnapshot + + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${testFiles.NON_BMP_TXT_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('non_bmp.txt')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import a project + testSnapshot = new Snapshot() + testSnapshot.addFile( + testFilePathname, + File.fromHash(testFiles.NON_BMP_TXT_HASH) + ) + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(response => { + // Check that the project is valid + expect(response.obj.projectId).to.equal(testProjectId) + }) + .then(() => { + // Import invalid changes + const changes = [ + makeChange(Operation.editFile(testFilePathname, testTextOperation)), + ] + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Expect invalid changes to fail + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + .then(() => { + // Get latest content + return clientForProject.apis.Project.getLatestContent({ + project_id: testProjectId, + }) + }) + .then(response => { + // Check that no changes were stored + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile(testFilePathname).getHash()).to.equal( + testFiles.NON_BMP_TXT_HASH + ) + }) + }) + + it('accepts text operation on files with null characters if stringLength is present', function () { + const testProjectId = '1' + const mainFilePathname = 'main.tex' + const testTextOperation = TextOperation.fromJSON([3, 'a']) + + let testSnapshot + + function importChanges() { + const changes = [ + makeChange(Operation.editFile(mainFilePathname, testTextOperation)), + ] + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + } + + function getLatestContent() { + return clientForProject.apis.Project.getLatestContent({ + project_id: testProjectId, + }) + } + + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${testFiles.NULL_CHARACTERS_TXT_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('null_characters.txt')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import project + testSnapshot = new Snapshot() + testSnapshot.addFile( + mainFilePathname, + File.fromHash(testFiles.NULL_CHARACTERS_TXT_HASH) + ) + return basicAuthClient.apis.ProjectImport.importSnapshot1({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + }) + .then(importChanges) + .then(() => { + // Expect invalid changes to fail + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + .then(getLatestContent) + .then(response => { + // Check that no chaes were made + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile(mainFilePathname).getHash()).to.equal( + testFiles.NULL_CHARACTERS_TXT_HASH + ) + }) + .then(() => { + // Set string length + return knex('project_blobs').update( + 'string_length', + testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH + ) + }) + .then(importChanges) + .then(getLatestContent) + .then(response => { + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile(mainFilePathname).getContent()).to.equal( + '\x00\x00\x00a' + ) + }) + }) + + it('returns 404 when chunk is not found in bucket', function () { + const testProjectId = '1' + const fooChange = makeChange(Operation.removeFile('foo.tex')) + + return knex('chunks') + .insert({ + doc_id: testProjectId, + start_version: 0, + end_version: 100, + end_timestamp: null, + }) + .then(() => { + // Import changes + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 100, + changes: [fooChange.toRaw()], + }) + }) + .then(() => { + // Expect invalid changes to fail + expect.fail() + }) + .catch(expectResponse.notFound) + }) + + it('creates and returns changes with v2 author ids', function () { + const testFilePathname = 'test.tex' + const testTextOperation = TextOperation.fromJSON(['a']) + const v2Authors = ['5a296963ad5e82432674c839', null] + + let testProjectId + + return BPromise.resolve(basicAuthClient.apis.Project.initializeProject()) + .then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + testProjectId = response.obj.projectId + expect(testProjectId).to.be.a('string') + }) + .then(() => { + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + }) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import changes + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [ + makeChange(Operation.addFile(testFilePathname, testFile)), + makeChange(Operation.editFile(testFilePathname, testTextOperation)), + ] + changes[1].setV2Authors(v2Authors) + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + return_snapshot: 'hashed', + changes: changes.map(changeToRaw), + }) + }) + .then(response => { + expect(response.status).to.equal(HTTPStatus.CREATED) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile('test.tex').getHash()).to.equal( + testFiles.STRING_A_HASH + ) + }) + .then(() => { + // Get project history + return pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({ + project_id: testProjectId, + }) + }) + .then(response => { + // it should not fail when the some of the authors do not exist anymore + const chunkResponse = ChunkResponse.fromRaw(response.obj) + const changes = chunkResponse.getChunk().getChanges() + expect(changes.length).to.equal(2) + const changeWithAuthor = changes[1] + expect(changeWithAuthor.getV2Authors()).to.deep.equal(v2Authors) + }) + }) + + it('should reject invalid v2 author ids', function () { + const testFilePathname = 'test.tex' + const v2Authors = ['not-a-v2-id'] + + let testProjectId + + return basicAuthClient.apis.Project.initializeProject() + .then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + testProjectId = response.obj.projectId + expect(testProjectId).to.be.a('string') + }) + .then(() => { + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + }) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import changes + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [ + makeChange(Operation.addFile(testFilePathname, testFile)), + ] + + changes[0].v2Authors = v2Authors + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Check that invalid changes fail + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + }) + + it('should reject changes with both v1 and v2 authors ids', function () { + const testFilePathname = 'test.tex' + const v1Authors = [456] + const v2Authors = ['5a296963ad5e82432674c839', null] + + let testProjectId + + return basicAuthClient.apis.Project.initializeProject() + .then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + testProjectId = response.obj.projectId + expect(testProjectId).to.be.a('string') + }) + .then(() => { + return fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('empty.tex')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + }) + .then(response => { + expect(response.ok).to.be.true + }) + .then(() => { + // Import changes + const testFile = File.fromHash(File.EMPTY_FILE_HASH) + const changes = [ + makeChange(Operation.addFile(testFilePathname, testFile)), + ] + + changes[0].authors = v1Authors + changes[0].v2Authors = v2Authors + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: testProjectId, + end_version: 0, + changes: changes.map(changeToRaw), + }) + }) + .then(() => { + // Check that invalid changes fail + expect.fail() + }) + .catch(expectResponse.unprocessableEntity) + }) + + it("returns unprocessable if end_version isn't provided", function () { + return basicAuthClient.apis.Project.initializeProject() + .then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + const projectId = response.obj.projectId + expect(projectId).to.be.a('string') + return projectId + }) + .then(projectId => { + // Import changes + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [], + }) + }) + .then(() => { + // Check that invalid changes fail + expect.fail() + }) + .catch(error => { + expect(error.message).to.equal( + 'Required parameter end_version is not provided' + ) + }) + }) + + it('returns unprocessable if return_snapshot is invalid', function () { + return basicAuthClient.apis.Project.initializeProject() + .then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + return response.obj.projectId + }) + .then(projectId => { + // Import changes + return basicAuthClient.apis.ProjectImport.importChanges1({ + project_id: projectId, + changes: [], + end_version: 0, + return_snapshot: 'not_a_valid_value', + }) + }) + .then(() => { + // Check that invalid changes fail + expect.fail() + }) + .catch(error => { + expect(error.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY) + expect(error.response.body.message).to.equal( + 'invalid enum value: return_snapshot' + ) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/projects.test.js b/services/history-v1/test/acceptance/js/api/projects.test.js new file mode 100644 index 0000000000..e533626d90 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/projects.test.js @@ -0,0 +1,201 @@ +'use strict' + +const { expect } = require('chai') +const fs = require('fs') +const HTTPStatus = require('http-status') +const fetch = require('node-fetch') +const sinon = require('sinon') + +const cleanup = require('../storage/support/cleanup') +const fixtures = require('../storage/support/fixtures') +const testFiles = require('../storage/support/test_files') + +const { zipStore, persistChanges } = require('../../../../storage') + +const { expectHttpError } = require('./support/expect_response') +const testServer = require('./support/test_server') +const { createEmptyProject } = require('./support/test_projects') + +const { + File, + Snapshot, + Change, + AddFileOperation, +} = require('overleaf-editor-core') + +describe('project controller', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + describe('initializeProject', function () { + let initializeProject + + before(function () { + initializeProject = + testServer.basicAuthClient.apis.Project.initializeProject + }) + + it('can initialize a new project', async function () { + const response = await initializeProject() + expect(response.status).to.equal(HTTPStatus.OK) + expect(response.obj.projectId).to.be.a('string') + }) + }) + + describe('createZip', function () { + let importSnapshot + let createZip + + before(function () { + importSnapshot = + testServer.basicAuthClient.apis.ProjectImport.importSnapshot1 + createZip = testServer.basicAuthClient.apis.Project.createZip + }) + + beforeEach(function () { + // Don't start the work in the background in this test --- it is flaky. + sinon.stub(zipStore, 'storeZip').resolves() + }) + afterEach(function () { + zipStore.storeZip.restore() + }) + + it('creates a URL to a zip file', async function () { + // Create a test blob. + const testProjectId = fixtures.docs.uninitializedProject.id + const response = await fetch( + testServer.url( + `/api/projects/${testProjectId}/blobs/${testFiles.HELLO_TXT_HASH}` + ), + { + method: 'PUT', + body: fs.createReadStream(testFiles.path('hello.txt')), + headers: { + Authorization: testServer.basicAuthHeader, + }, + } + ) + expect(response.ok).to.be.true + + // Import a project with the test blob. + const testFilePathname = 'hello.txt' + const testSnapshot = new Snapshot() + testSnapshot.addFile( + testFilePathname, + File.fromHash(testFiles.HELLO_TXT_HASH) + ) + + const importResponse = await importSnapshot({ + project_id: testProjectId, + snapshot: testSnapshot.toRaw(), + }) + expect(importResponse.obj.projectId).to.equal(testProjectId) + + const createZipResponse = await createZip({ + project_id: testProjectId, + version: 0, + }) + expect(createZipResponse.status).to.equal(HTTPStatus.OK) + const zipInfo = createZipResponse.obj + expect(zipInfo.zipUrl).to.match( + /^http:\/\/gcs:9090\/download\/storage\/v1\/b\/overleaf-test-zips/ + ) + expect(zipStore.storeZip.calledOnce).to.be.true + }) + }) + + // eslint-disable-next-line mocha/no-skipped-tests + describe.skip('getLatestContent', function () { + // TODO: remove this endpoint entirely, see + // https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862 + }) + + describe('getLatestHashedContent', function () { + let limitsToPersistImmediately + + before(function () { + // used to provide a limit which forces us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + }) + + it('returns a snaphot', async function () { + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('ab'))], + new Date(), + [] + ), + ] + + const projectId = await createEmptyProject() + await persistChanges(projectId, changes, limitsToPersistImmediately, 0) + const response = + await testServer.basicAuthClient.apis.Project.getLatestHashedContent({ + project_id: projectId, + }) + expect(response.status).to.equal(HTTPStatus.OK) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(1) + expect(snapshot.getFile('test.tex').getHash()).to.equal( + testFiles.STRING_AB_HASH + ) + }) + }) + + describe('deleteProject', function () { + it('deletes the project chunks', async function () { + const projectId = fixtures.docs.initializedProject.id + const historyResponse = + await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory( + { + project_id: projectId, + } + ) + expect(historyResponse.status).to.equal(HTTPStatus.OK) + expect(historyResponse.body).to.have.property('chunk') + const deleteResponse = + await testServer.basicAuthClient.apis.Project.deleteProject({ + project_id: projectId, + }) + expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT) + await expectHttpError( + testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({ + project_id: projectId, + }), + HTTPStatus.NOT_FOUND + ) + }) + + it('deletes the project blobs', async function () { + const projectId = fixtures.docs.initializedProject.id + const token = testServer.createTokenForProject(projectId) + const authHeaders = { Authorization: `Bearer ${token}` } + const hash = testFiles.HELLO_TXT_HASH + const fileContents = await fs.promises.readFile( + testFiles.path('hello.txt') + ) + const blobUrl = testServer.url(`/api/projects/${projectId}/blobs/${hash}`) + const response1 = await fetch(blobUrl, { + method: 'PUT', + headers: authHeaders, + body: fileContents, + }) + expect(response1.ok).to.be.true + const response2 = await fetch(blobUrl, { headers: authHeaders }) + const payload = await response2.text() + expect(payload).to.equal(fileContents.toString()) + const deleteResponse = + await testServer.basicAuthClient.apis.Project.deleteProject({ + project_id: projectId, + }) + expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT) + const response3 = await fetch(blobUrl, { headers: authHeaders }) + expect(response3.status).to.equal(HTTPStatus.NOT_FOUND) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/support/expect_response.js b/services/history-v1/test/acceptance/js/api/support/expect_response.js new file mode 100644 index 0000000000..cdab3d35e6 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/support/expect_response.js @@ -0,0 +1,53 @@ +'use strict' + +const { expect } = require('chai') +const HTTPStatus = require('http-status') + +function expectStatus(err, expected) { + const httpStatus = err.status || err.statusCode + if (httpStatus === undefined) { + throw err + } else { + expect(httpStatus).to.equal(expected) + } +} + +async function expectHttpError(promise, expectedStatusCode) { + try { + await promise + } catch (err) { + const statusCode = err.status || err.statusCode + if (statusCode === undefined) { + throw err + } else { + expect(statusCode).to.equal(expectedStatusCode) + return + } + } + expect.fail('expected HTTP request to return with an error response') +} + +exports.expectHttpError = expectHttpError +exports.notFound = function (err) { + expectStatus(err, HTTPStatus.NOT_FOUND) +} + +exports.unprocessableEntity = function (err) { + expectStatus(err, HTTPStatus.UNPROCESSABLE_ENTITY) +} + +exports.conflict = function (err) { + expectStatus(err, HTTPStatus.CONFLICT) +} + +exports.unauthorized = function (err) { + expectStatus(err, HTTPStatus.UNAUTHORIZED) +} + +exports.forbidden = function (err) { + expectStatus(err, HTTPStatus.FORBIDDEN) +} + +exports.requestEntityTooLarge = function (err) { + expectStatus(err, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) +} diff --git a/services/history-v1/test/acceptance/js/api/support/test_projects.js b/services/history-v1/test/acceptance/js/api/support/test_projects.js new file mode 100644 index 0000000000..9e97d8c77d --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/support/test_projects.js @@ -0,0 +1,17 @@ +const BPromise = require('bluebird') +const { expect } = require('chai') +const HTTPStatus = require('http-status') +const assert = require('../../../../../storage/lib/assert') + +const testServer = require('./test_server') + +exports.createEmptyProject = function () { + return BPromise.resolve( + testServer.basicAuthClient.apis.Project.initializeProject() + ).then(response => { + expect(response.status).to.equal(HTTPStatus.OK) + const { projectId } = response.obj + assert.projectId(projectId, 'bad projectId') + return projectId + }) +} diff --git a/services/history-v1/test/acceptance/js/api/support/test_server.js b/services/history-v1/test/acceptance/js/api/support/test_server.js new file mode 100644 index 0000000000..a0753b908f --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/support/test_server.js @@ -0,0 +1,133 @@ +/** + * @file + * Create a test server. For performance reasons, there is only one test server, + * and it is shared between all of the tests. + * + * This uses the mocha's "root-level hooks" to start and clean up the server. + */ + +const BPromise = require('bluebird') +const config = require('config') +const http = require('http') +const jwt = require('jsonwebtoken') + +const Swagger = require('swagger-client') + +const app = require('../../../../../app') + +function testUrl(pathname, opts = {}) { + const url = new URL('http://localhost') + url.port = exports.server.address().port + url.pathname = pathname + if (opts.qs) { + url.searchParams = new URLSearchParams(opts.qs) + } + return url.toString() +} + +exports.url = testUrl + +function createClient(options) { + // The Swagger client returns native Promises; we use Bluebird promises. Just + // wrapping the client creation is enough in many (but not all) cases to + // get Bluebird into the chain. + return BPromise.resolve(new Swagger(testUrl('/api-docs'), options)) +} + +function createTokenForProject(projectId, opts = {}) { + const jwtKey = opts.jwtKey || config.get('jwtAuth.key') + const jwtAlgorithm = config.get('jwtAuth.algorithm') + return jwt.sign({ project_id: projectId }, jwtKey, { + algorithm: jwtAlgorithm, + }) +} + +exports.createTokenForProject = createTokenForProject + +function createClientForProject(projectId, opts = {}) { + const token = createTokenForProject(projectId, opts) + return createClient({ authorizations: { jwt: `Bearer ${token}` } }) +} + +exports.createClientForProject = createClientForProject + +function createClientForDownloadZip(projectId) { + const token = createTokenForProject(projectId) + return createClient({ authorizations: { token } }) +} + +exports.createClientForDownloadZip = createClientForDownloadZip + +function createBasicAuthClient() { + return createClient({ + authorizations: { + basic: { + username: 'staging', + password: config.get('basicHttpAuth.password'), + }, + }, + }) +} + +function createPseudoJwtBasicAuthClient() { + // HACK: The history service will accept HTTP basic auth for any endpoint that + // is expecting a JWT. If / when we fix that, we will need to fix this. + const jwt = + 'Basic ' + + Buffer.from(`staging:${config.get('basicHttpAuth.password')}`).toString( + 'base64' + ) + return createClient({ authorizations: { jwt } }) +} + +exports.basicAuthHeader = + 'Basic ' + + Buffer.from(`staging:${config.get('basicHttpAuth.password')}`).toString( + 'base64' + ) + +function createServer() { + const server = http.createServer(app) + return app.setup().then(() => { + exports.server = server + return server + }) +} + +function createDefaultUnauthenticatedClient() { + return createClient().then(client => { + exports.client = client + }) +} + +function createDefaultBasicAuthClient() { + return createBasicAuthClient().then(client => { + exports.basicAuthClient = client + }) +} + +function createDefaultPseudoJwtBasicAuthClient() { + return createPseudoJwtBasicAuthClient().then(client => { + exports.pseudoJwtBasicAuthClient = client + }) +} + +before(function () { + function listenOnRandomPort(server) { + const listen = BPromise.promisify(server.listen, { context: server }) + return listen(0).catch(err => { + if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err + return listenOnRandomPort(server) + }) + } + + return createServer() + .then(listenOnRandomPort) + .then(createDefaultUnauthenticatedClient) + .then(createDefaultBasicAuthClient) + .then(createDefaultPseudoJwtBasicAuthClient) +}) + +after(function () { + exports.server.close() +}) diff --git a/services/history-v1/test/acceptance/js/storage/batch_blob_store.test.js b/services/history-v1/test/acceptance/js/storage/batch_blob_store.test.js new file mode 100644 index 0000000000..645ea5919e --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/batch_blob_store.test.js @@ -0,0 +1,52 @@ +'use strict' + +const { expect } = require('chai') + +const cleanup = require('./support/cleanup') +const testFiles = require('./support/test_files') + +const core = require('overleaf-editor-core') +const File = core.File + +const storage = require('../../../../storage') +const BatchBlobStore = storage.BatchBlobStore +const BlobStore = storage.BlobStore + +const projectId = '123' +const blobStore = new BlobStore(projectId) +const batchBlobStore = new BatchBlobStore(blobStore) + +describe('BatchBlobStore', function () { + beforeEach(cleanup.everything) + + it('can preload and batch getBlob calls', async function () { + // Add some test files + await Promise.all([ + blobStore.putFile(testFiles.path('graph.png')), + blobStore.putFile(testFiles.path('hello.txt')), + ]) + + // Cache some blobs (one that exists and another that doesn't) + await batchBlobStore.preload([ + testFiles.GRAPH_PNG_HASH, + File.EMPTY_FILE_HASH, // not found + ]) + expect(batchBlobStore.blobs.size).to.equal(1) + + const [cached, notCachedExists, notCachedNotExists, duplicate] = + await Promise.all([ + batchBlobStore.getBlob(testFiles.GRAPH_PNG_HASH), // cached + batchBlobStore.getBlob(testFiles.HELLO_TXT_HASH), // not cached; exists + batchBlobStore.getBlob(File.EMPTY_FILE_HASH), // not cached; not exists + batchBlobStore.getBlob(testFiles.GRAPH_PNG_HASH), // duplicate + ]) + + expect(cached.getHash()).to.equal(testFiles.GRAPH_PNG_HASH) + expect(notCachedExists.getHash()).to.equal(testFiles.HELLO_TXT_HASH) + expect(notCachedNotExists).to.be.undefined + expect(duplicate.getHash()).to.equal(testFiles.GRAPH_PNG_HASH) + + // We should get exactly the object from the cache. + expect(cached).to.equal(batchBlobStore.blobs.get(testFiles.GRAPH_PNG_HASH)) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/blob_hash.test.js b/services/history-v1/test/acceptance/js/storage/blob_hash.test.js new file mode 100644 index 0000000000..77e4cc3a0e --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/blob_hash.test.js @@ -0,0 +1,15 @@ +'use strict' + +const { expect } = require('chai') +const storage = require('../../../../storage') +const blobHash = storage.blobHash + +describe('blobHash', function () { + it('can hash non-ASCII strings', function () { + // checked with git hash-object + const testString = 'å\n' + const testHash = 'aad321caf77ca6c5ab09e6c638c237705f93b001' + + expect(blobHash.fromString(testString)).to.equal(testHash) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/blob_store.test.js b/services/history-v1/test/acceptance/js/storage/blob_store.test.js new file mode 100644 index 0000000000..8f624881fa --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/blob_store.test.js @@ -0,0 +1,442 @@ +'use strict' + +const _ = require('lodash') +const { expect } = require('chai') +const config = require('config') +const fs = require('fs') +const path = require('path') +const { Readable } = require('stream') +const temp = require('temp').track() +const { promisify } = require('util') + +const cleanup = require('./support/cleanup') +const testFiles = require('./support/test_files') + +const { Blob, TextOperation } = require('overleaf-editor-core') +const { + BlobStore, + loadGlobalBlobs, + mongodb, + persistor, + streams, +} = require('../../../../storage') +const mongoBackend = require('../../../../storage/lib/blob_store/mongo') +const postgresBackend = require('../../../../storage/lib/blob_store/postgres') + +const mkTmpDir = promisify(temp.mkdir) + +describe('BlobStore', function () { + const helloWorldString = 'Hello World' + const helloWorldHash = '5e1c309dae7f45e0f39b1bf3ac3cd9db12e7d689' + const globalBlobString = 'a' + const globalBlobHash = testFiles.STRING_A_HASH + const demotedBlobString = 'ab' + const demotedBlobHash = testFiles.STRING_AB_HASH + + beforeEach(cleanup.everything) + + beforeEach('install a global blob', async function () { + await mongodb.globalBlobs.insertOne({ + _id: globalBlobHash, + byteLength: globalBlobString.length, + stringLength: globalBlobString.length, + }) + await mongodb.globalBlobs.insertOne({ + _id: demotedBlobHash, + byteLength: demotedBlobString.length, + stringLength: demotedBlobString.length, + demoted: true, + }) + const bucket = config.get('blobStore.globalBucket') + for (const { key, content } of [ + { + key: '2e/65/efe2a145dda7ee51d1741299f848e5bf752e', + content: globalBlobString, + }, + { + key: '9a/e9/e86b7bd6cb1472d9373702d8249973da0832', + content: demotedBlobString, + }, + ]) { + const stream = Readable.from([content]) + await persistor.sendStream(bucket, key, stream) + } + await loadGlobalBlobs() + }) + + const scenarios = [ + { + description: 'Postgres backend', + projectId: '123', + projectId2: '456', + backend: postgresBackend, + }, + { + description: 'Mongo backend', + projectId: '63725f84b2bdd246ec8c0000', + projectId2: '63725f84b2bdd246ec8c1234', + backend: mongoBackend, + }, + ] + for (const scenario of scenarios) { + describe(scenario.description, function () { + const blobStore = new BlobStore(scenario.projectId) + const blobStore2 = new BlobStore(scenario.projectId2) + + beforeEach('initialize the blob stores', async function () { + await blobStore.initialize() + await blobStore2.initialize() + }) + + it('can store and fetch string content', async function () { + function checkBlob(blob) { + expect(blob.getHash()).to.equal(helloWorldHash) + expect(blob.getByteLength()).to.equal(helloWorldString.length) + expect(blob.getStringLength()).to.equal(helloWorldString.length) + } + + const insertedBlob = await blobStore.putString(helloWorldString) + checkBlob(insertedBlob) + const fetchedBlob = await blobStore.getBlob(helloWorldHash) + checkBlob(fetchedBlob) + const content = await blobStore.getString(helloWorldHash) + expect(content).to.equal(helloWorldString) + }) + + it('can store and fetch utf-8 files', async function () { + const testFile = 'hello.txt' + + function checkBlob(blob) { + expect(blob.getHash()).to.equal(testFiles.HELLO_TXT_HASH) + expect(blob.getByteLength()).to.equal(testFiles.HELLO_TXT_BYTE_LENGTH) + expect(blob.getStringLength()).to.equal( + testFiles.HELLO_TXT_UTF8_LENGTH + ) + } + + const insertedBlob = await blobStore.putFile(testFiles.path(testFile)) + checkBlob(insertedBlob) + const fetchedBlob = await blobStore.getBlob(testFiles.HELLO_TXT_HASH) + checkBlob(fetchedBlob) + const content = await blobStore.getString(testFiles.HELLO_TXT_HASH) + expect(content).to.equal('Olá mundo\n') + }) + + it('can store and fetch a large text file', async function () { + const testString = _.repeat('a', 1000000) + const testHash = 'de1fbf0c2f34f67f01f355f31ed0cf7319643c5e' + + function checkBlob(blob) { + expect(blob.getHash()).to.equal(testHash) + expect(blob.getByteLength()).to.equal(testString.length) + expect(blob.getStringLength()).to.equal(testString.length) + } + + const dir = await mkTmpDir('blobStore') + const pathname = path.join(dir, 'a.txt') + fs.writeFileSync(pathname, testString) + const insertedBlob = await blobStore.putFile(pathname) + checkBlob(insertedBlob) + const fetchedBlob = await blobStore.getBlob(testHash) + checkBlob(fetchedBlob) + const content = await blobStore.getString(testHash) + expect(content).to.equal(testString) + }) + + it('stores overlarge text files as binary', async function () { + const testString = _.repeat('a', TextOperation.MAX_STRING_LENGTH + 1) + const dir = await mkTmpDir('blobStore') + const pathname = path.join(dir, 'a.txt') + fs.writeFileSync(pathname, testString) + const blob = await blobStore.putFile(pathname) + expect(blob.getByteLength()).to.equal(testString.length) + expect(blob.getStringLength()).not.to.exist + }) + + it('can store and fetch binary files', async function () { + const testFile = 'graph.png' + + function checkBlob(blob) { + expect(blob.getHash()).to.equal(testFiles.GRAPH_PNG_HASH) + expect(blob.getByteLength()).to.equal(testFiles.GRAPH_PNG_BYTE_LENGTH) + expect(blob.getStringLength()).to.be.null + } + + const insertedBlob = await blobStore.putFile(testFiles.path(testFile)) + checkBlob(insertedBlob) + const fetchedBlob = await blobStore.getBlob(testFiles.GRAPH_PNG_HASH) + checkBlob(fetchedBlob) + const stream = await blobStore.getStream(testFiles.GRAPH_PNG_HASH) + const buffer = await streams.readStreamToBuffer(stream) + expect(buffer.length).to.equal(testFiles.GRAPH_PNG_BYTE_LENGTH) + expect(buffer.toString('hex', 0, 8)).to.equal( + testFiles.PNG_MAGIC_NUMBER + ) + }) + + const missingHash = 'deadbeef00000000000000000000000000000000' + + it('fails to get a missing key as a string', async function () { + try { + await blobStore.getString(missingHash) + } catch (err) { + expect(err).to.be.an.instanceof(Blob.NotFoundError) + expect(err.hash).to.equal(missingHash) + return + } + expect.fail('expected NotFoundError') + }) + + it('fails to get a missing key as a stream', async function () { + try { + await blobStore.getStream(missingHash) + } catch (err) { + expect(err).to.be.an.instanceof(Blob.NotFoundError) + return + } + expect.fail('expected NotFoundError') + }) + + it('reads invalid utf-8 as utf-8', async function () { + // We shouldn't do this, but we need to know what happens if we do. + // TODO: We should throw an error instead, but this function doesn't have + // an easy way of checking the content type. + const testFile = 'graph.png' + await blobStore.putFile(testFiles.path(testFile)) + const content = await blobStore.getString(testFiles.GRAPH_PNG_HASH) + expect(content.length).to.equal(12902) + }) + + it('checks for non BMP characters', async function () { + const testFile = 'non_bmp.txt' + await blobStore.putFile(testFiles.path(testFile)) + const blob = await blobStore.getBlob(testFiles.NON_BMP_TXT_HASH) + expect(blob.getStringLength()).to.be.null + expect(blob.getByteLength()).to.equal(testFiles.NON_BMP_TXT_BYTE_LENGTH) + }) + + it('can fetch metadata for multiple blobs at once', async function () { + await blobStore.putFile(testFiles.path('graph.png')) + const blobs = await blobStore.getBlobs([ + testFiles.GRAPH_PNG_HASH, + testFiles.HELLO_TXT_HASH, // not found + testFiles.GRAPH_PNG_HASH, // requested twice + ]) + const hashes = blobs.map(blob => blob.getHash()) + expect(hashes).to.deep.equal([testFiles.GRAPH_PNG_HASH]) + }) + + describe('multiple blobs in the same project', async function () { + beforeEach(async function () { + await blobStore.putString(helloWorldString) + await blobStore.putFile(testFiles.path('graph.png')) + await blobStore.putFile(testFiles.path('hello.txt')) + }) + + it('getBlob() returns each blob', async function () { + const helloBlob = await blobStore.getBlob(testFiles.HELLO_TXT_HASH) + const graphBlob = await blobStore.getBlob(testFiles.GRAPH_PNG_HASH) + const helloWorldBlob = await blobStore.getBlob(helloWorldHash) + expect(helloBlob.hash).to.equal(testFiles.HELLO_TXT_HASH) + expect(graphBlob.hash).to.equal(testFiles.GRAPH_PNG_HASH) + expect(helloWorldBlob.hash).to.equal(helloWorldHash) + }) + + it('getBlobs() returns all blobs', async function () { + const blobs = await blobStore.getBlobs([ + testFiles.HELLO_TXT_HASH, + testFiles.GRAPH_PNG_HASH, + testFiles.NON_BMP_TXT_HASH, // not in blob store + ]) + const actualHashes = blobs.map(blob => blob.hash) + expect(actualHashes).to.have.members([ + testFiles.HELLO_TXT_HASH, + testFiles.GRAPH_PNG_HASH, + ]) + }) + }) + + describe('two blob stores on different projects', function () { + beforeEach(async function () { + await blobStore.putString(helloWorldString) + await blobStore2.putFile(testFiles.path('graph.png')) + }) + + it('separates blobs when calling getBlob()', async function () { + const blobFromStore1 = await blobStore.getBlob(helloWorldHash) + const blobFromStore2 = await blobStore2.getBlob(helloWorldHash) + expect(blobFromStore1).to.exist + expect(blobFromStore2).not.to.exist + }) + + it('separates blobs when calling getBlobs()', async function () { + const blobsFromStore1 = await blobStore.getBlobs([ + helloWorldHash, + testFiles.GRAPH_PNG_HASH, + ]) + const blobsFromStore2 = await blobStore2.getBlobs([ + helloWorldHash, + testFiles.GRAPH_PNG_HASH, + ]) + expect(blobsFromStore1.map(blob => blob.getHash())).to.deep.equal([ + helloWorldHash, + ]) + expect(blobsFromStore2.map(blob => blob.getHash())).to.deep.equal([ + testFiles.GRAPH_PNG_HASH, + ]) + }) + + it('separates blobs when calling getStream()', async function () { + await blobStore2.getStream(testFiles.GRAPH_PNG_HASH) + try { + await blobStore.getStream(testFiles.GRAPH_PNG_HASH) + } catch (err) { + expect(err).to.be.an.instanceof(Blob.NotFoundError) + return + } + expect.fail( + 'expected Blob.NotFoundError when calling blobStore.getStream()' + ) + }) + + it('separates blobs when calling getString()', async function () { + const content = await blobStore.getString(helloWorldHash) + expect(content).to.equal(helloWorldString) + try { + await blobStore2.getString(helloWorldHash) + } catch (err) { + expect(err).to.be.an.instanceof(Blob.NotFoundError) + return + } + expect.fail( + 'expected Blob.NotFoundError when calling blobStore.getStream()' + ) + }) + }) + + describe('a global blob', function () { + it('is available through getBlob()', async function () { + const blob = await blobStore.getBlob(globalBlobHash) + expect(blob.getHash()).to.equal(globalBlobHash) + }) + + it('is available through getBlobs()', async function () { + await blobStore.putString(helloWorldString) + const requestedHashes = [globalBlobHash, helloWorldHash] + const blobs = await blobStore.getBlobs(requestedHashes) + const hashes = blobs.map(blob => blob.getHash()) + expect(hashes).to.have.members(requestedHashes) + }) + + it('is available through getString()', async function () { + const content = await blobStore.getString(globalBlobHash) + expect(content).to.equal('a') + }) + + it('is available through getStream()', async function () { + const stream = await blobStore.getStream(globalBlobHash) + const buffer = await streams.readStreamToBuffer(stream) + expect(buffer.toString()).to.equal(globalBlobString) + }) + + it("doesn't prevent putString() from adding the same blob", async function () { + const blob = await blobStore.putString(globalBlobString) + expect(blob.getHash()).to.equal(globalBlobHash) + const projectBlob = await scenario.backend.findBlob( + scenario.projectId, + globalBlobHash + ) + expect(projectBlob).not.to.exist + }) + + it("doesn't prevent putFile() from adding the same blob", async function () { + const dir = await mkTmpDir('blobStore') + const pathname = path.join(dir, 'blob.txt') + fs.writeFileSync(pathname, globalBlobString) + const blob = await blobStore.putFile(pathname) + expect(blob.getHash()).to.equal(globalBlobHash) + const projectBlob = await scenario.backend.findBlob( + scenario.projectId, + globalBlobHash + ) + expect(projectBlob).not.to.exist + }) + }) + + describe('a demoted global blob', function () { + it('is available through getBlob()', async function () { + const blob = await blobStore.getBlob(demotedBlobHash) + expect(blob.getHash()).to.equal(demotedBlobHash) + }) + + it('is available through getBlobs()', async function () { + await blobStore.putString(helloWorldString) + const requestedHashes = [demotedBlobHash, helloWorldHash] + const blobs = await blobStore.getBlobs(requestedHashes) + const hashes = blobs.map(blob => blob.getHash()) + expect(hashes).to.have.members(requestedHashes) + }) + + it('is available through getString()', async function () { + const content = await blobStore.getString(demotedBlobHash) + expect(content).to.equal(demotedBlobString) + }) + + it('is available through getStream()', async function () { + const stream = await blobStore.getStream(demotedBlobHash) + const buffer = await streams.readStreamToBuffer(stream) + expect(buffer.toString()).to.equal(demotedBlobString) + }) + + it("doesn't prevent putString() from creating a project blob", async function () { + const blob = await blobStore.putString(demotedBlobString) + expect(blob.getHash()).to.equal(demotedBlobHash) + const projectBlob = await scenario.backend.findBlob( + scenario.projectId, + demotedBlobHash + ) + expect(projectBlob).to.exist + }) + + it("doesn't prevent putFile() from creating a project blob", async function () { + const dir = await mkTmpDir('blobStore') + const pathname = path.join(dir, 'blob.txt') + fs.writeFileSync(pathname, demotedBlobString) + const blob = await blobStore.putFile(pathname) + expect(blob.getHash()).to.equal(demotedBlobHash) + const projectBlob = await scenario.backend.findBlob( + scenario.projectId, + demotedBlobHash + ) + expect(projectBlob).to.exist + }) + }) + + describe('deleting blobs', async function () { + beforeEach('install a project blob', async function () { + await blobStore.putString(helloWorldString) + const blob = await blobStore.getBlob(helloWorldHash) + expect(blob).to.exist + }) + + beforeEach('delete project blobs', async function () { + await blobStore.deleteBlobs() + }) + + it('deletes project blobs', async function () { + try { + await blobStore.getString(helloWorldHash) + expect.fail('expected NotFoundError') + } catch (err) { + expect(err).to.be.an.instanceof(Blob.NotFoundError) + } + }) + + it('retains global blobs', async function () { + const content = await blobStore.getString(globalBlobHash) + expect(content).to.equal(globalBlobString) + }) + }) + }) + } +}) diff --git a/services/history-v1/test/acceptance/js/storage/blob_store_mongo.test.js b/services/history-v1/test/acceptance/js/storage/blob_store_mongo.test.js new file mode 100644 index 0000000000..bc4f7309e0 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/blob_store_mongo.test.js @@ -0,0 +1,126 @@ +const { expect } = require('chai') +const { ObjectId, Binary } = require('mongodb') +const { Blob } = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const mongoBackend = require('../../../../storage/lib/blob_store/mongo') +const mongodb = require('../../../../storage/lib/mongodb') + +describe('BlobStore Mongo backend', function () { + const projectId = ObjectId().toString() + const hashes = { + abcd: [ + 'abcd000000000000000000000000000000000000', + 'abcd111111111111111111111111111111111111', + 'abcd222222222222222222222222222222222222', + 'abcd333333333333333333333333333333333333', + 'abcd444444444444444444444444444444444444', + 'abcd555555555555555555555555555555555555', + 'abcd666666666666666666666666666666666666', + 'abcd777777777777777777777777777777777777', + 'abcd888888888888888888888888888888888888', + 'abcd999999999999999999999999999999999999', + 'abcdaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ], + 1234: ['1234000000000000000000000000000000000000'], + } + + beforeEach('clean up', cleanup.everything) + + beforeEach('initialize the project', async function () { + await mongoBackend.initialize(projectId) + }) + + describe('insertBlob', function () { + it('writes blobs to the projectHistoryBlobs collection', async function () { + for (const hash of hashes.abcd + .slice(0, 2) + .concat(hashes[1234].slice(0, 1))) { + const blob = new Blob(hash, 123, 99) + await mongoBackend.insertBlob(projectId, blob) + } + const record = await mongodb.blobs.findOne(ObjectId(projectId), { + promoteBuffers: true, + }) + expect(record.blobs).to.deep.equal({ + abc: hashes.abcd.slice(0, 2).map(hash => ({ + h: Buffer.from(hash, 'hex'), + b: 123, + s: 99, + })), + 123: [{ h: Buffer.from(hashes[1234][0], 'hex'), b: 123, s: 99 }], + }) + }) + + it('writes excess blobs to the projectHistoryShardedBlobs collection', async function () { + for (const hash of hashes.abcd.concat(hashes[1234])) { + const blob = new Blob(hash, 123, 99) + await mongoBackend.insertBlob(projectId, blob) + } + const record = await mongodb.blobs.findOne(ObjectId(projectId), { + promoteBuffers: true, + }) + expect(record.blobs).to.deep.equal({ + abc: hashes.abcd + .slice(0, 8) + .map(hash => ({ h: Buffer.from(hash, 'hex'), b: 123, s: 99 })), + 123: [{ h: Buffer.from(hashes[1234][0], 'hex'), b: 123, s: 99 }], + }) + const shardedRecord = await mongodb.shardedBlobs.findOne( + { _id: new Binary(Buffer.from(`${projectId}0a`, 'hex')) }, + { promoteBuffers: true } + ) + expect(shardedRecord.blobs).to.deep.equal({ + bcd: hashes.abcd + .slice(8) + .map(hash => ({ h: Buffer.from(hash, 'hex'), b: 123, s: 99 })), + }) + }) + }) + + describe('with existing blobs', function () { + beforeEach(async function () { + for (const hash of hashes.abcd.concat(hashes[1234])) { + const blob = new Blob(hash, 123, 99) + await mongoBackend.insertBlob(projectId, blob) + } + }) + + describe('findBlob', function () { + it('finds blobs in the projectHistoryBlobs collection', async function () { + const blob = await mongoBackend.findBlob(projectId, hashes.abcd[0]) + expect(blob.getHash()).to.equal(hashes.abcd[0]) + }) + + it('finds blobs in the projectHistoryShardedBlobs collection', async function () { + const blob = await mongoBackend.findBlob(projectId, hashes.abcd[9]) + expect(blob.getHash()).to.equal(hashes.abcd[9]) + }) + }) + + describe('findBlobs', function () { + it('finds blobs in the projectHistoryBlobs collection', async function () { + const requestedHashes = hashes.abcd.slice(0, 3).concat(hashes[1234]) + const blobs = await mongoBackend.findBlobs(projectId, requestedHashes) + const obtainedHashes = blobs.map(blob => blob.getHash()) + expect(obtainedHashes).to.have.members(requestedHashes) + }) + + it('finds blobs in the projectHistoryShardedBlobs collection', async function () { + const requestedHashes = [1, 3, 5, 8, 9].map(idx => hashes.abcd[idx]) + const blobs = await mongoBackend.findBlobs(projectId, requestedHashes) + const obtainedHashes = blobs.map(blob => blob.getHash()) + expect(obtainedHashes).to.have.members(requestedHashes) + }) + }) + + describe('deleteBlobs', function () { + it('deletes all blobs for a given project', async function () { + await mongoBackend.deleteBlobs(projectId) + const recordCount = await mongodb.blobs.count() + const shardedRecordCount = await mongodb.shardedBlobs.count() + expect(recordCount).to.equal(0) + expect(shardedRecordCount).to.equal(0) + }) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js new file mode 100644 index 0000000000..59eb5ec3ee --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -0,0 +1,338 @@ +'use strict' + +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const { expect } = require('chai') +const sinon = require('sinon') +const { ObjectId } = require('mongodb') + +const { + Chunk, + Snapshot, + Change, + History, + File, + Operation, + AddFileOperation, + EditFileOperation, + TextOperation, +} = require('overleaf-editor-core') +const { chunkStore, historyStore } = require('../../../../storage') + +describe('chunkStore', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + const scenarios = [ + { + description: 'Postgres backend', + createProject: chunkStore.initializeProject, + }, + { + description: 'Mongo backend', + createProject: () => + chunkStore.initializeProject(new ObjectId().toString()), + }, + ] + + for (const scenario of scenarios) { + describe(scenario.description, function () { + let projectId + + beforeEach(async function () { + projectId = await scenario.createProject() + }) + + it('loads empty latest chunk for a new project', async function () { + const chunk = await chunkStore.loadLatest(projectId) + expect(chunk.getSnapshot().countFiles()).to.equal(0) + expect(chunk.getChanges().length).to.equal(0) + expect(chunk.getEndTimestamp()).not.to.exist + }) + + describe('adding and editing a blank file', function () { + const testPathname = 'foo.txt' + const testTextOperation = TextOperation.fromJSON(['a']) // insert an a + let lastChangeTimestamp + + beforeEach(async function () { + const chunk = await chunkStore.loadLatest(projectId) + const oldEndVersion = chunk.getEndVersion() + const changes = [ + makeChange(Operation.addFile(testPathname, File.fromString(''))), + makeChange(Operation.editFile(testPathname, testTextOperation)), + ] + lastChangeTimestamp = changes[1].getTimestamp() + chunk.pushChanges(changes) + await chunkStore.update(projectId, oldEndVersion, chunk) + }) + + it('records the correct timestamp', async function () { + const chunk = await chunkStore.loadLatest(projectId) + expect(chunk.getEndTimestamp()).to.deep.equal(lastChangeTimestamp) + }) + + it('records changes', async function () { + const chunk = await chunkStore.loadLatest(projectId) + const history = chunk.getHistory() + expect(history.getSnapshot().countFiles()).to.equal(0) + expect(history.getChanges().length).to.equal(2) + const addChange = history.getChanges()[0] + expect(addChange.getOperations().length).to.equal(1) + const addFile = addChange.getOperations()[0] + expect(addFile).to.be.an.instanceof(AddFileOperation) + expect(addFile.getPathname()).to.equal(testPathname) + const file = addFile.getFile() + expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH) + expect(file.getByteLength()).to.equal(0) + expect(file.getStringLength()).to.equal(0) + const editChange = history.getChanges()[1] + expect(editChange.getOperations().length).to.equal(1) + const editFile = editChange.getOperations()[0] + expect(editFile).to.be.an.instanceof(EditFileOperation) + expect(editFile.getPathname()).to.equal(testPathname) + }) + }) + + describe('multiple chunks', async function () { + // Two chunks are 1 year apart + const firstChunkTimestamp = new Date('2015-01-01T00:00:00') + const secondChunkTimestamp = new Date('2016-01-01T00:00:00') + const thirdChunkTimestamp = new Date('2017-01-01T00:00:00') + let firstChunk, secondChunk, thirdChunk + + beforeEach(async function () { + firstChunk = makeChunk( + [ + makeChange( + Operation.addFile('foo.tex', File.fromString('')), + new Date(firstChunkTimestamp - 5000) + ), + makeChange( + Operation.addFile('bar.tex', File.fromString('')), + firstChunkTimestamp + ), + ], + 0 + ) + await chunkStore.update(projectId, 0, firstChunk) + firstChunk = await chunkStore.loadLatest(projectId) + + secondChunk = makeChunk( + [ + makeChange( + Operation.addFile('baz.tex', File.fromString('')), + new Date(secondChunkTimestamp - 5000) + ), + makeChange( + Operation.addFile('qux.tex', File.fromString('')), + secondChunkTimestamp + ), + ], + 2 + ) + await chunkStore.create(projectId, secondChunk) + secondChunk = await chunkStore.loadLatest(projectId) + + thirdChunk = makeChunk( + [ + makeChange( + Operation.addFile('quux.tex', File.fromString('')), + thirdChunkTimestamp + ), + ], + 4 + ) + await chunkStore.create(projectId, thirdChunk) + thirdChunk = await chunkStore.loadLatest(projectId) + }) + + it('returns the second chunk when querying for a version between the start and end version', async function () { + const chunk = await chunkStore.loadAtVersion(projectId, 3) + expect(chunk).to.deep.equal(secondChunk) + + // Check file lazy loading + const history = chunk.getHistory() + expect(history.getSnapshot().countFiles()).to.equal(0) + expect(history.getChanges().length).to.equal(2) + + const change = history.getChanges()[0] + expect(change.getOperations().length).to.equal(1) + + const addFile = change.getOperations()[0] + expect(addFile).to.be.an.instanceof(AddFileOperation) + expect(addFile.getPathname()).to.equal('baz.tex') + + const file = addFile.getFile() + expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH) + expect(file.getByteLength()).to.equal(0) + expect(file.getStringLength()).to.equal(0) + }) + + it('returns the first chunk when querying for the end version of the chunk', async function () { + const chunk = await chunkStore.loadAtVersion(projectId, 2) + expect(chunk).to.deep.equal(firstChunk) + }) + + it('returns the second chunk when querying for a timestamp between the second and third chunk', async function () { + const searchTimestamp = new Date('2015-07-01T00:00:00') + const chunk = await chunkStore.loadAtTimestamp( + projectId, + searchTimestamp + ) + expect(chunk).to.deep.equal(secondChunk) + + // Check file lazy loading + const history = chunk.getHistory() + expect(history.getSnapshot().countFiles()).to.equal(0) + expect(history.getChanges().length).to.equal(2) + + const change = history.getChanges()[0] + expect(change.getOperations().length).to.equal(1) + + const addFile = change.getOperations()[0] + expect(addFile).to.be.an.instanceof(AddFileOperation) + expect(addFile.getPathname()).to.equal('baz.tex') + + const file = addFile.getFile() + expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH) + expect(file.getByteLength()).to.equal(0) + expect(file.getStringLength()).to.equal(0) + }) + + it('returns the third chunk when querying for a timestamp past the latest chunk', async function () { + const searchTimestampPastLatestChunk = new Date('2018-01-01T00:00:00') + const chunk = await chunkStore.loadAtTimestamp( + projectId, + searchTimestampPastLatestChunk + ) + // Check that we found the third chunk + expect(chunk).to.deep.equal(thirdChunk) + }) + + describe('after updating the last chunk', function () { + let newChunk + + beforeEach(async function () { + newChunk = makeChunk( + [ + ...thirdChunk.getChanges(), + makeChange( + Operation.addFile('onemore.tex', File.fromString('')), + thirdChunkTimestamp + ), + ], + 4 + ) + await chunkStore.update(projectId, 5, newChunk) + newChunk = await chunkStore.loadLatest(projectId) + }) + + it('replaces the latest chunk', function () { + expect(newChunk.getChanges()).to.have.length(2) + }) + + it('returns the right chunk when querying by version', async function () { + const chunk = await chunkStore.loadAtVersion(projectId, 5) + expect(chunk).to.deep.equal(newChunk) + }) + + it('returns the right chunk when querying by timestamp', async function () { + const chunk = await chunkStore.loadAtTimestamp( + projectId, + thirdChunkTimestamp + ) + expect(chunk).to.deep.equal(newChunk) + }) + }) + }) + + describe('when saving to object storage fails', function () { + beforeEach(function () { + sinon.stub(historyStore, 'storeRaw').rejects(new Error('S3 Error')) + }) + + afterEach(function () { + historyStore.storeRaw.restore() + }) + + it('does not create chunks', async function () { + const oldEndVersion = 0 + const testPathname = 'foo.txt' + const testTextOperation = TextOperation.fromJSON(['a']) // insert an a + + let chunk = await chunkStore.loadLatest(projectId) + expect(chunk.getEndVersion()).to.equal(oldEndVersion) + + const changes = [ + makeChange(Operation.addFile(testPathname, File.fromString(''))), + makeChange(Operation.editFile(testPathname, testTextOperation)), + ] + chunk.pushChanges(changes) + + await expect( + chunkStore.update(projectId, oldEndVersion, chunk) + ).to.be.rejectedWith('S3 Error') + chunk = await chunkStore.loadLatest(projectId) + expect(chunk.getEndVersion()).to.equal(oldEndVersion) + }) + }) + + describe('version checks', function () { + beforeEach(async function () { + // Create a chunk with start version 0, end version 3 + const chunk = makeChunk( + [ + makeChange(Operation.addFile('main.tex', File.fromString('abc'))), + makeChange( + Operation.editFile( + 'main.tex', + TextOperation.fromJSON([3, 'def']) + ) + ), + makeChange( + Operation.editFile( + 'main.tex', + TextOperation.fromJSON([6, 'ghi']) + ) + ), + ], + 0 + ) + await chunkStore.update(projectId, 0, chunk) + }) + + it('refuses to create a chunk with the same start version', async function () { + const chunk = makeChunk( + [makeChange(Operation.addFile('main.tex', File.fromString('abc')))], + 0 + ) + await expect(chunkStore.create(projectId, chunk)).to.be.rejectedWith( + chunkStore.ChunkVersionConflictError + ) + }) + + it("allows creating chunks that don't have version conflicts", async function () { + const chunk = makeChunk( + [makeChange(Operation.addFile('main.tex', File.fromString('abc')))], + 3 + ) + await chunkStore.create(projectId, chunk) + }) + }) + }) + } +}) + +function makeChange(operation, date = new Date()) { + return new Change([operation], date, []) +} + +function makeChunk(changes, versionNumber) { + const snapshot = Snapshot.fromRaw({ files: {} }) + const history = new History(snapshot, []) + const chunk = new Chunk(history, versionNumber) + + chunk.pushChanges(changes) + return chunk +} diff --git a/services/history-v1/test/acceptance/js/storage/files/empty.tex b/services/history-v1/test/acceptance/js/storage/files/empty.tex new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/history-v1/test/acceptance/js/storage/files/graph.png b/services/history-v1/test/acceptance/js/storage/files/graph.png new file mode 100644 index 0000000000000000000000000000000000000000..81dac49dc128aa0a7d0263d24c0d1ce14de554a8 GIT binary patch literal 13476 zcmeHucT^PHw`MgtNf60G3y9>Pyfl#a`TPucfn|PO`8og;h7s-=VT0HBb~m*h0f4w3 z{R3&TeS04O1Vj~NuHSPtSV*i6omX^EB!OXKoTt`!^8-^70Z+LF$6!sp-v(#ahpWkZ zvWHINJffhybXQEckaw#5pgBv2%b_w`dsyFAiiGm>rMa;Cn{6deuU2;+OsD_=9G?*Z z05C;7J^+Y4i$Mec8GZmJ0B8Tdd)sDwbGiu{8rC`TE@g7AXvCa$Gix%NnD+~kb0M+W zB-DX;eP-BY%1Bx|(5i;BXFQ|7OlnTOjn#cy%!911qNdT^eLWtD)5>`@I$Nb(469E$ zN7yLU`K7tU`%ps+@3D#eoy+nx*FC4ov{ksuOLPC{BSp&( z#&3_8a$F&TIFqN?NJ{E7OjwFMYPTrbT#l{U9AmJjU4J!l|Eiazph*7u4NbAvQUeaw zWr->iUxw`Z<2l_u12y+958acEW<=O9ed}nN&dk<)$DmnPSe-Lr$9ZXXhnO%o$8$!G zZEF9K?t5Y~Dqge3>kTwZ$$htbr=H^1e>J~Om{N1~N;uWeT$}tKk!x(Cl8Sn>oMHH! z6r3^U#V(Q#q_2w4;!qUV88>LvEIIdf4PIRv=i1=a({$OoBsKQhN)*5IF;hwj1B!f% z6koK-SyRmC4gZEZuk-`X+nlPw&s^?&aT_e894N-X)mL;&a+lA^Tg>d*@a>{_>Rh2A zua%edLVK`{yz6?mLk}p0@jD*FMD@h-r{6w&)8fi9{p-r*rOBC;Zxc3IJC+)ruV4D! z@bmwAyYS;QOk-F*kL+^AayQbd;#}mq zx^7^yo?fiOw3zNA#W43^yXT2=9b91N$Fq<5TIMLWLi2^Juu*ngD{~Rwa^0Wz_=Pf! z=`rRn^%$?v$@W)dt+>msoNJ(LaVljm)C-B%jxWT<4Y2bwC`(NxHhV}MO}EN^DbRG* z0@>X0WW7*`P{YK3BK4dS(nKaOCEhpB#9QpKT-dynuz^`#cBJq9hgYjpZ`d|AQr4_q z*PM_TN_HD5c0~Ahh$5dVYf6%jlo*s`QdcD?r1iCRQLSEy$Ichiu>W>*xTF{sv6@2? z$`&9Y(z@Y_WW1km&>ij`WFLRW78~@sii5o#H6btYb9eL7mCtAjmXXS_-1qr(-RZ#f zSwR_3qDXAFNNtp|Vc~7h(1zy5CstikbwX$A7VkVV`|)+3?;DAP7*u6=xK8DHTN5{` z-*l*x^<|j0pnDdN%0$kU6vsrp#L4TLVu7qgc8MQ{!|-?G9Z89I==80+Di5T;sCII0 z2t~}U+em46rUj;A$46H0%13@oO%)*46k14B*1AGgx&Emq)WsL5daf#5sJ3tRJ+L8S zt~{swrJiRvpO~hJ!3DA&3(cu9MvNJe?w`KexJ?|lFW({Bh~G{N3J<~V{r>pn#E&0I z!>K>iQj|99|FV0A({?BOVE?Xm=rq-a_l~DjrAXN7&sJyoZX~|^u#`>$seSgnE|N+D zJ$I20OSXw4(Y|*rALRyhQ_ZsB^vbup8)e6x%jBLb5s}oeS4BRky@b?Wg5KE)4OOcl zB6GVO_`Lv%pS={86Usgp|MYO*@6`57M}B%|ylipOQ-7pfA$L)8!c2#)-T6Ahc_z}Z zi5lv6Q6Kr|MztD1pQv4b+0fjP_Qt}IL0*1yogY68C+ty3U343if>6aA1zL_Nru}XUWLvQ@;+c5*ugc8#i$?5ge=X5ZGbj|(s`e<6 zaa=0pbef0CMuU1zej05)+T0YGI!CKWoy7XdGzcGgd5*r{@w|a+w{~*(_1-`$9@_oj zf-2E~Ge$D*>vEa{y1j;Mw+kijmuTiPOy4VBeQ#V14eK`W@~*vP$?;0xG+t3)q)_xp1IfcuVp(b&5;)7#!`f7 zat*~r^(}Eco<-68l)k}hJ^jWql6l>1A0w@K+Yd|)H2Kp=JQb#* zS!sj)A6iKpIf&~qYDv>$WHE$qRqPncX8rU1#^Q_F4XLCaUcFy^+bsv0-$IE+4FI?B zAvgeVR~ilh5Rs%X0N`W!FK~M`4cM*R3*Xw>OC0d>_DU1#L~;t|-{_?TX-XzmtV$M3 zh=)Y_!?KDFwq9!HJZd}uSl$aG?e%jgj81N(?D;OX0RW>5ZVqt&urnrH+c`Top-e(I zvhnx&{~le8`Q6=J6a<)GTS6TyweTXa^4G)* zUb2)gx?(ss#Fc^^uJ6fZ1AatL*0+?_Yh%O%vX!Ip;ZX%GTeaHUKJ^=w};yto$ zw6PENDy;_xnCZ$}q_|6jgrc)D<@Zt?O(eP&r+u5MekTk0Yu6W^I>9OS-L$XOPhCegsG4Ml^wV=z^FNM#a^dRI7{yhwa9j zfOYM8ENV7Je1Z|fRb*IO<{UfbEqR-8JPR)Mi?2txV)8Sq7RuSJ%%ZoY!u*7>b@>`2 zFsucOS`CY;DR_FU_h>juNS3y%GHNc}7}#d4K3_#v#D&DoS(?988#t54>qMVB)1-I# z?Dun^FLdG+d@UwubBO<9tw?7jWPlkp;Nxvhe7?qY!i&1%Yv2pR8n5(Z1441mm1-DF zm7)_dxdce=2~pnN%LsmWJ3tr*y=BaH@mm5HYD(f_*4~VYcQnquRjdhm2^cmNm-6EnmPvFcc{7I=KFeCV*eglP3>t?0J? zK)|uS&WIST1tZAysc-o`oj1*yUtg+PIer^Q zN0Zm*_^Q6kCxPIQfRcgXrUE(#F1ST70+C^;eQ)T+fcL?7D*0U955x8Kvt5#N5S%d3 za^T>=a?~|4*s|9D|3}+9gQ&&S>`x`lv3H|*h`uu%Vg=jy0Z!z9*(+A;zFwC7Hb26c z{BLVqZ@<}m+CRglPyOL4=p&271=RX+nl(~VN#{-OSqh&}gbPkzFLGkdWx!_H$xX-a z&b#4fyL76@7G4(TQ#~=wXM8@?b0gPtN>KV$@q$-mo5HUj87*&s{wBhL_{eklW6zmn z6O~XtjuJY1Z%joh99t4F5fo#zH+AovmxC*Zl-EGg34P0O!KiVAGCr2}@Lys%AT^wm z4`E-K73!?Kwijp8??7>Shbr4=13J^r$*)fdrRq)W%uFhi$)!6pFWO$4sT*AY(GJW* zx;Nep*pt92oKmPCPc*$eJ%I~OtT)fVSZjecbE~aK8VOl^in?Dvh3Y9$6V|XU^*ij> zza&A3bag6NZFPvCqJiCm1Lto1&7;KtJU-oovoIc=i}|J8XQWjH`CF#rlD(DS_@~#) z#L`;%dG4=$%H0{H){`K>9p{9;%{`wpPkw1tQ85evpB87~<#M!pJyl$g8ybOs^{*X7 z<3SEkg$cq?8Bb(B>HPHK-Fqh0=vGEv4jW8SSSr!DRk+4S!|C$y^oDLs=; zw>I_Y&=012r1q7vnpRV!P&tuwLCMMI<7bC=3$3bp7Mq{V@qE5Wa`5i*9hCq&P?`3M z@NK%q7Pk@~a8LX&<9%o{28ucf;Sf%V)Uh?tt#2?N_3|m#7BGANg`~4^(mlo`&-|

?~?c`E|`&HcFjUj(@@lc;gDQ8mu7nHPK;ExTr!;|&J7yHZXLR; ztyOUz51tsg&u(!_$j0Q1dUdobO*4O**xh0W&2x_iCOuXZN?vFC1BI9IoUZ(!eCn<# zO1{h?jc#`xshKOyO}?rS!$B98Ss#Udb%FWQnO)(H8?V)>KXat;DzeLID#qErN9P;@NeFug)l)&&Q2ARIWHln&a8aq*_N>7 z2;G60yy~=m3li4UCToF3Zo*!lLqL=r@_xL(Z)mH)8?a=JtK~Y`8j+QjZQjxxHH>9> zyHDUOY!e&^8k6v8!74@>H8?pA;@j%Xx+Z&*Y7VYoC6%NTOf;)K%lXk{s|^MjEDQkS zf*q3K(#<(!3c6GrWUUp&Qc!cSqOxvy-$uinOvI~LzuGG!P3?B9vHw)FjN4CRMH?HT zJb}1T9JKP+Swa^)hd+8lalpkI5g&Gxc4S};6zL?-;hle2#&M@P^H|nvgV6ej1RPh?S))k`H)0AH(EB`eTe{$&y!Y7o z1R!4AwW&}xczAYUj_!qe1dUSVZ z&3nHWw;8uD!~WT8s=uK>4Zm+|m#uVulV(0if|2;O>c5F&O{(b!9?5fUa)RvA@VU8%BrOAcFbpcCaFSRL{s9wKTcn+!1EX(PXPx%tf7!K^hM z=iA!bV<4Iw%<^|^P8E{E(0S+DldrZ(_;lxps-;T@MHgpG%s1Xru$NBZpKgAPIqpq=LjnuBZCU{<<8X=Ue%E7`@V8Hc zCcC5?3XR@dmMe=tr>QNxL7R`xVTWsj?hVG|M9zrMwgBtBh<4K6FT*2E1N$rF*>mRNeh zi6Uy}TX&e=_e~@o$7VtVW&is{{fcsdrQpi=J9Ex83CC{6atxo~@b zCs@+TgU{P|UlP45OJjV1Ik`oZ;{Y55vbDJEo#f5;YLPKgn|Kg1_MV&E&r3%XUqn{T zITwG4Am{2zh;rF`m6xkB_8w5mx0oDi)RC*9_Ffy)*n5S5Mq@E= za6s#&N*h$Pu)Q_0;p|+wX$x-s65?9;ZWjpPXn>0Zf)-J)lB*JodYE0Nc`ED+T%Qwh zy7VrXnfQ~W-~Z8}9bysB`S{!(G10jSsbi4q#4q?uQ!;I9f5+$2!uC8Hm3GehgB6U! z1-q$h9ABs(Jo@T<*}8-XXP8L69I`-C>;$x==Q1;t`k*(@#39) zgkMVP{lR;+bIq)RALQQww(TUt%#4UiH23+HTFQ6vbN1I{cdUOtmvTPJs7EM@4yvDI z<5OiY6>sB=Bf|9SrBN3cSlS)KEjW<$f9Dbe?KZ=f4R8IWIf{ecXa9%}bRUZssy#O% z?Cpb>dU{!~iqJFg&+nK<8MS_0mH0>Qd@A9?z1!*ccSy%%uCoUWx!T;E+%mKNp2sS@ z<9RhkWAFjafb_?NPmAi?;cY5=ohZ8E@Q!}*A?#qW`mUa_`GkHyFGkh+wy&!9OO0dPX=})UEGIp-03Zv(jpgz-uwZ z`4yAE^sZ$B3tNUVYa;ROPhA=Y=|A%18!38x+!`MyoP&+|Sw8+eC4*Oht}buVn>&jV z2tRZ8%C!DD*ru#St*Kzr^Q>{y$}rEUiE6JOiQRZb1i=w)rO2yL{+Hdl*Zpe7knL`~ z{KP?vP_B1ovU_tnVY(W+&O+JNl;E2EKi>7I2$&-i7ZkpOM7T$6$^6DI{T8>4(Zecc zhMb4E=;*(n#~5>DE_ptcLl!b7@bz{-!}1xYHdTf#tZ0S&KzDj$lvq(hk&UKJT!xT* zo^Ad}wi$O0^Ve^dF9x+WxY$T0AJDsWX{qJb6trjMS!qkFtO|i?HP1K94@4?3VSKYU zi>VjCw(9G#OA#U%CfPkgQ*U^x<4(}yM}cL$LRwXvI;cqt57Q8~vk zD`jcd?UZt5yTxT&$Hg~26w=r#lc0Ymntl1<)s{i9u#s^QtIGc#ap5RLof7(y%fzHh zqPCQQTD3hq1I0ZfJyb)3TP4$aUUP*rNY~r&1}lB{-f(2(hlP>2a^&DR=QjrJm*x$S z?UX75w=ON_1>|C-e#nYAt~N`+E#{aWiGo-M@=WYl@szlvVo|aq`l8nKN{lFPwYsT70JMyr#RX`F@w9>hrkezA8#A^6n$?qTWF<)g+3lvh1x* zqivpv&q2IF!eN{y#^<}O@|0tE%PNQs;?~MY%UIp$&|rDYu86%SY;7tlFT39u&bV#g zp!WPgt_J#C-tde~dS{Id8p(LP*Unz`(G-{!A3F&#Nif38*@kr&@C)b-CUJw$o?u-d z6%H;=LX_&QG0U%$Uq6VfExv6?RkL7DeMI(u@#SF0Bt+%2mPpL@1zUd05D}@J=zqcs zTAcy8%j%JWh&bqtylsl2z*#W<9J;7qcMg78-m08 znH`%pxn8l9o4Xarw(F|jJM6m;sB*zuJ8$iF>f^?cMuOjGl4)7L9E->o)+`!xBBywB=BtU}y%L5t5follC@_i7FOi{w${8>w9h!ZujA$ciWSBw-b^16_wxl%=S0CiZpjN zDmzMYP*|-=*;CpxaH|_D*|E#Ke%MLY9hnY2F(L+wvd2jd)CUWWhm*Ap%w-gq#5YFPJL90s7vaxl!;j2YC1Pt>SciCh7!hktH_n}oqODBNX z(;!M{V5Iw^COqX4sQsS5C@qd+#a{gwpW0hRp^O^5orjzV1bnpIMBm_Q$dKtmNU2|2 z9JQ4ujqIGWX{v>#V;WtooxAdyzI@ud?h9qbIU-jxJ^jkZT6oEhvNu|{msK$pqlA-; zAQ~6_WIi`dPt$@ka7K@hGMPGK=Ddkx$+@GRVBj95Eqd6Q_|uDN(lnGQS5X2rp&U0Z z+E%@?Xe(=w9YBM*2*O*NIsa_gUaWE|_Hh~ZFJXz^V?&9;XMLIlsdnvEcPNb&$%aGc z*Gr37I|GLY)ji(wjF{%!wsi(|HO}(r142pWgsn#oWgY`b$qEHV>SK=gpV%Q&UD+OP zk5Dux>w7j}yz01&UM7F*?csLOEXU$TIH3Mv8JojoSahwgN^1lS|Q zslmjQ1=08-U>1XJ6137CwG-!mmCtDNgSI#d1F%K?`(SZQE-!6nuR*y{rSrw;cE#xh zRE~HJ+98iNd{q9g2tK}xra!0eqvW57FAMHipxM=_mC*sXmU2X8PCf8Nlfi#3+NiAp z)7_%)%lU1E%ai!?n}mDN*msA`Td*#2eh&6xLH7h(1k^vtn850u`tQ)ji3Gz8Y$-Hi z#iDM71M%p(|0w8xJ(1fdo}U>}O#8#mO#YI8)){a_rB10A{IrHf^V1XECMZ~DG~Vmu z+iQb$qHt@EiP@?BiOjjcoQ$_gC;p^pFqxOOWGlb9%5V~Ue(m$~4t=Z#*~*Nebeh`k zZ`$?cwTJ(v#N(3e5TyS0A#-f4t;Ljj)a)F#7pB}%L-h2BOwZ@_^3l8em#7TKV`+Yu z6Q3_eb4-`c3ZBc|3AQlNRN$0$f*Dm{Z3p~+vh~FLh>vWZGVk`=FAH+5nPp&Ie$2K{ zdyxj*i8O*8w=d4@qlrSc*@WPqMy(_OL7i667^Rgv6SV4u;!o9!S2+Htbr2k^h*$Aq6 ziPN{aS(bgf9C}huONZ9Wn&^_%GgwaB1TS~T0dOK3EuF6Akr~DMC!($2H(ZBzIc?w zFM^2{{joy*yX(J7mQ%_$p@umER`PG1|?$<#$L6(R_Fx>reCKpMyzet4aDBKJ_1BLNJ8> zwov}Mc)=4Sm4PPi{}^vRSRaw#`rq*?Dj8v{L6M^F-%s*KBvpizdRWE^#q{aK2OWSP zyAMAMotB~`39-kl@#YpsvR-^6^tjo1dWb0qTvUI`T_?!Z->Lcn!oYWL%wk2bZ9TQC zddc8Gsw3n zly0qb9Y+n;(@=rO2R3K&<(LDV844VpY(p}URFI9?V>ZKx0K1RXl#-FHY>G8>gG#JY zdv9z0v=u6*GsooRxP-bS>6%+kN#mGzEOE=O>9rXHS`vsE5yrg1 zq=vwS8V^0AimHO*%*|tp^1%=%axs17P)CchHnNzY3mmf4r5H%MyTy^m zfxGzv|L-OKPZbr+jxj&?@_@amP1ACl1|vuJI8G+_ue^T58xjEUH)27i#^=D(6hq~b zbzqVCyV3KZFe|V9?dhe?ux#>RU@rPNj@)X9*oKuRz=&O0g=qL|keNwchdU|#uUmH> z>j*vpIOa=3IKC^!@sT;hap3UmT;m*WtV#j3?p!0tIDrdbMG<=eVeBxxL-h(ZA$7p~ z5CUL z2(Fr%j~>?B=a~@zku==L2|VVP3J#;kAVRQVQELSNn0(+u;JJGs`1i;E(Puns=T@Ay zO;UDTRQIHUiyXEs1s)6Ct!s9w&;MMLhXI3N5E%sR7%p*a&W>ttI$V*R^;~cQ<&e98 z7)`gEQJe0FyJnO6N<98q7EW|m7atTBqvT<&i?btI_>ex~rV|) zBhgDX=}L@X9}ht8mk5&(&P{fdxFkje?Bu#49In~Fv~hfe4TL>KkIlS>H%6{~<)Vy| z0)@R5HS!+Es`^Ef|U?YhYq5lK?w_>|Nw}DH6hfs{|JHHQY%m#2RQvn~RdK)jLgqWvu_l|4#F# g|KP#jUh71WOp$-YQp~NP1HBqhki9LFCk=b@Z { + return persistChanges(projectId, changes, limitsToPersistImmediately, 0) + }) + .then(result => { + const history = new History(new Snapshot(), changes) + const currentChunk = new Chunk(history, 0) + expect(result).to.deep.equal({ + numberOfChangesPersisted: 1, + originalEndVersion: 0, + currentChunk, + }) + return chunkStore.loadLatest(projectId) + }) + .then(chunk => { + expect(chunk.getStartVersion()).to.equal(0) + expect(chunk.getEndVersion()).to.equal(1) + expect(chunk.getChanges().length).to.equal(1) + }) + }) + + it('persists changes in two chunks', function () { + const limitsToPersistImmediately = { + maxChunkChanges: 1, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + const projectId = fixtures.docs.uninitializedProject.id + const firstChange = new Change( + [new AddFileOperation('a.tex', File.fromString(''))], + new Date(), + [] + ) + const secondChange = new Change( + [new AddFileOperation('b.tex', File.fromString(''))], + new Date(), + [] + ) + const changes = [firstChange, secondChange] + + return chunkStore + .initializeProject(projectId) + .then(() => { + return persistChanges(projectId, changes, limitsToPersistImmediately, 0) + }) + .then(result => { + const snapshot = Snapshot.fromRaw({ + files: { + 'a.tex': { + content: '', + }, + }, + }) + const history = new History(snapshot, [secondChange]) + const currentChunk = new Chunk(history, 1) + expect(result).to.deep.equal({ + numberOfChangesPersisted: 2, + originalEndVersion: 0, + currentChunk, + }) + return chunkStore.loadLatest(projectId) + }) + .then(chunk => { + expect(chunk.getStartVersion()).to.equal(1) + expect(chunk.getEndVersion()).to.equal(2) + expect(chunk.getChanges().length).to.equal(1) + }) + }) + + it('persists the snapshot at the start of the chunk', function () { + const limitsToPersistImmediately = { + maxChunkChanges: 2, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + const projectId = fixtures.docs.uninitializedProject.id + const firstChange = new Change( + [new AddFileOperation('a.tex', File.fromString(''))], + new Date(), + [] + ) + const secondChange = new Change( + [new AddFileOperation('b.tex', File.fromString(''))], + new Date(), + [] + ) + const changes = [firstChange, secondChange] + + return chunkStore + .initializeProject(projectId) + .then(() => { + return persistChanges(projectId, changes, limitsToPersistImmediately, 0) + }) + .then(result => { + const history = new History(new Snapshot(), changes) + const currentChunk = new Chunk(history, 0) + expect(result).to.deep.equal({ + numberOfChangesPersisted: 2, + originalEndVersion: 0, + currentChunk, + }) + return chunkStore.loadLatest(projectId) + }) + .then(chunk => { + expect(chunk.getStartVersion()).to.equal(0) + expect(chunk.getEndVersion()).to.equal(2) + expect(chunk.getChanges().length).to.equal(2) + }) + }) + + it("errors if the version doesn't match the latest chunk", function () { + const limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + const projectId = fixtures.docs.uninitializedProject.id + const firstChange = new Change( + [new AddFileOperation('a.tex', File.fromString(''))], + new Date(), + [] + ) + const secondChange = new Change( + [new AddFileOperation('b.tex', File.fromString(''))], + new Date(), + [] + ) + const changes = [firstChange, secondChange] + return chunkStore + .initializeProject(projectId) + .then(() => { + return persistChanges(projectId, changes, limitsToPersistImmediately, 1) + }) + .then(() => { + expect.fail() + }) + .catch(err => { + expect(err.message).to.equal( + 'client sent updates with end_version 1 but latest chunk has end_version 0' + ) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/project_archive.test.js b/services/history-v1/test/acceptance/js/storage/project_archive.test.js new file mode 100644 index 0000000000..a41c9f6732 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/project_archive.test.js @@ -0,0 +1,204 @@ +'use strict' + +const _ = require('lodash') +const BPromise = require('bluebird') +const { expect } = require('chai') +const fs = BPromise.promisifyAll(require('fs')) +const sinon = require('sinon') +const stream = require('stream') +const temp = require('temp') + +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const testFiles = require('./support/test_files') +const unzip = require('./support/unzip') + +const core = require('overleaf-editor-core') +const File = core.File +const Snapshot = core.Snapshot + +const storage = require('../../../../storage') +const BlobStore = storage.BlobStore +const ProjectArchive = storage.ProjectArchive + +describe('ProjectArchive', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + const projectId = '123' + const blobStore = new BlobStore(projectId) + + let zipFilePath + beforeEach(function () { + zipFilePath = temp.path({ suffix: '.zip' }) + }) + afterEach(function () { + return fs.unlinkAsync(zipFilePath).catch(() => {}) + }) + + function makeMixedTestSnapshot(rounds) { + const snapshot = new Snapshot() + + return blobStore.putFile(testFiles.path('graph.png')).then(() => { + _.times(rounds, i => { + snapshot.addFile('test' + i + '.txt', File.fromString('test')) + snapshot.addFile( + 'graph' + i + '.png', + File.fromHash(testFiles.GRAPH_PNG_HASH) + ) + }) + return snapshot + }) + } + + function makeTextTestSnapshot(rounds) { + const snapshot = new Snapshot() + _.times(rounds, i => { + snapshot.addFile('test' + i + '.txt', File.fromString('test')) + }) + return snapshot + } + + it('archives a small snapshot with binary and text data', function () { + return makeMixedTestSnapshot(1) + .then(snapshot => { + const projectArchive = new ProjectArchive(snapshot) + return projectArchive.writeZip(blobStore, zipFilePath) + }) + .then(() => { + return unzip.getZipEntries(zipFilePath) + }) + .then(zipEntries => { + expect(zipEntries).to.have.length(2) + zipEntries = _.sortBy(zipEntries, 'fileName') + expect(zipEntries[0].fileName).to.equal('graph0.png') + expect(zipEntries[0].uncompressedSize).to.equal( + testFiles.GRAPH_PNG_BYTE_LENGTH + ) + expect(zipEntries[1].fileName).to.equal('test0.txt') + expect(zipEntries[1].uncompressedSize).to.equal(4) + }) + }) + + it('archives a larger snapshot with binary and text data', function () { + return makeMixedTestSnapshot(10) + .then(snapshot => { + const projectArchive = new ProjectArchive(snapshot) + return projectArchive.writeZip(blobStore, zipFilePath) + }) + .then(() => { + return unzip.getZipEntries(zipFilePath) + }) + .then(zipEntries => { + expect(zipEntries).to.have.length(20) + }) + }) + + it('archives empty files', function () { + const snapshot = new Snapshot() + snapshot.addFile('test0', File.fromString('')) + snapshot.addFile('test1', File.fromHash(File.EMPTY_FILE_HASH)) + + return blobStore + .putString('') + .then(() => { + const projectArchive = new ProjectArchive(snapshot) + return projectArchive.writeZip(blobStore, zipFilePath) + }) + .then(() => { + return unzip.getZipEntries(zipFilePath) + }) + .then(zipEntries => { + zipEntries = _.sortBy(zipEntries, 'fileName') + expect(zipEntries[0].fileName).to.equal('test0') + expect(zipEntries[0].uncompressedSize).to.equal(0) + expect(zipEntries[1].fileName).to.equal('test1') + expect(zipEntries[1].uncompressedSize).to.equal(0) + }) + }) + + describe('with a blob stream download error', function () { + beforeEach(function () { + const testStream = new stream.Readable({ + read: function () { + testStream.emit('error', new Error('test read error')) + }, + }) + sinon.stub(blobStore, 'getStream').resolves(testStream) + }) + + afterEach(function () { + blobStore.getStream.restore() + }) + + it('rejects with the error', function () { + return makeMixedTestSnapshot(1) + .then(snapshot => { + const projectArchive = new ProjectArchive(snapshot) + return projectArchive.writeZip(blobStore, zipFilePath) + }) + .then(() => { + expect.fail() + }) + .catch(err => { + expect(err.message).to.match(/test read error/) + }) + }) + }) + + describe('with zip write error', function () { + beforeEach(function () { + sinon.stub(fs, 'createWriteStream').callsFake(path => { + const testStream = new stream.Writable({ + write: function () { + testStream.emit('error', new Error('test write error')) + }, + }) + return testStream + }) + }) + + afterEach(function () { + fs.createWriteStream.restore() + }) + + it('rejects with the error', function () { + return makeMixedTestSnapshot(1) + .then(snapshot => { + const projectArchive = new ProjectArchive(snapshot) + return projectArchive.writeZip(blobStore, zipFilePath) + }) + .then(() => { + expect.fail() + }) + .catch(err => { + expect(err.message).to.equal('test write error') + }) + }) + }) + + describe('with a delayed file load', function () { + beforeEach(function () { + sinon.stub(File.prototype, 'load').callsFake(function () { + return BPromise.delay(200).thenReturn(this) + }) + }) + + afterEach(function () { + File.prototype.load.restore() + }) + + it('times out', function () { + const snapshot = makeTextTestSnapshot(10) + const projectArchive = new ProjectArchive(snapshot, 100) + return projectArchive + .writeZip(blobStore, zipFilePath) + .then(() => { + expect.fail() + }) + .catch(err => { + expect(err.name).to.equal('ArchiveTimeout') + }) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/project_key.test.js b/services/history-v1/test/acceptance/js/storage/project_key.test.js new file mode 100644 index 0000000000..4aa6c1f504 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/project_key.test.js @@ -0,0 +1,21 @@ +'use strict' + +const { expect } = require('chai') + +const { format, pad } = require('../../../../storage/lib/project_key') + +describe('projectKey', function () { + it('reverses padded keys', function () { + expect(format(1)).to.equal('100/000/000') + expect(format(12)).to.equal('210/000/000') + expect(format(123456789)).to.equal('987/654/321') + expect(format(9123456789)).to.equal('987/654/3219') + }) + + it('pads numbers with zeros to length 9', function () { + expect(pad(1)).to.equal('000000001') + expect(pad(10)).to.equal('000000010') + expect(pad(100000000)).to.equal('100000000') + expect(pad(1000000000)).to.equal('1000000000') + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/support/cleanup.js b/services/history-v1/test/acceptance/js/storage/support/cleanup.js new file mode 100644 index 0000000000..89eff859bb --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/cleanup.js @@ -0,0 +1,64 @@ +const config = require('config') + +const { knex, persistor, mongodb } = require('../../../../../storage') + +const POSTGRES_TABLES = [ + 'chunks', + 'project_blobs', + 'old_chunks', + 'pending_chunks', +] + +const MONGO_COLLECTIONS = [ + 'projectHistoryGlobalBlobs', + 'projectHistoryBlobs', + 'projectHistoryShardedBlobs', + 'projectHistoryChunks', +] + +// make sure we don't delete the wrong data by accident +if (process.env.NODE_ENV !== 'test') { + throw new Error('test cleanup can only be loaded in a test environment') +} + +async function cleanupPostgres() { + for (const table of POSTGRES_TABLES) { + await knex(table).del() + } +} + +async function cleanupMongo() { + const collections = await mongodb.db.listCollections().map(c => c.name) + for await (const collection of collections) { + if (MONGO_COLLECTIONS.includes(collection)) { + await mongodb.db.collection(collection).deleteMany({}) + } + } +} + +async function cleanupPersistor() { + await Promise.all([ + clearBucket(config.get('blobStore.globalBucket')), + clearBucket(config.get('blobStore.projectBucket')), + clearBucket(config.get('chunkStore.bucket')), + clearBucket(config.get('zipStore.bucket')), + ]) +} + +async function clearBucket(name) { + await persistor.deleteDirectory(name, '') +} + +async function cleanupEverything() { + // Set the timeout when called in a Mocha test. This function is also called + // in benchmarks where it is not passed a Mocha context. + this.timeout?.(5000) + await Promise.all([cleanupPostgres(), cleanupMongo(), cleanupPersistor()]) +} + +module.exports = { + postgres: cleanupPostgres, + mongo: cleanupMongo, + persistor: cleanupPersistor, + everything: cleanupEverything, +} diff --git a/services/history-v1/test/acceptance/js/storage/support/fetch.js b/services/history-v1/test/acceptance/js/storage/support/fetch.js new file mode 100644 index 0000000000..316f52130e --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/fetch.js @@ -0,0 +1,6 @@ +const BPromise = require('bluebird') +const fetch = require('node-fetch') + +fetch.Promise = BPromise + +module.exports = fetch diff --git a/services/history-v1/test/acceptance/js/storage/support/fixtures.js b/services/history-v1/test/acceptance/js/storage/support/fixtures.js new file mode 100644 index 0000000000..f077b3e8ad --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/fixtures.js @@ -0,0 +1,20 @@ +'use strict' + +const BPromise = require('bluebird') +const dbSpecs = require('../fixtures').dbSpecs +const knex = require('../../../../../storage').knex +const historyStore = require('../../../../../storage').historyStore + +function createFixtures() { + return knex('chunks') + .insert(dbSpecs.chunks) + .then(() => { + return BPromise.mapSeries(dbSpecs.histories, history => + historyStore.storeRaw(history.projectId, history.chunkId, history.json) + ) + }) +} + +exports.create = createFixtures +exports.chunks = require('../fixtures/chunks').chunks +exports.docs = require('../fixtures/docs').docs diff --git a/services/history-v1/test/acceptance/js/storage/support/test_files.js b/services/history-v1/test/acceptance/js/storage/support/test_files.js new file mode 100644 index 0000000000..86d9062435 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/test_files.js @@ -0,0 +1,27 @@ +const path = require('path') + +exports.path = function (pathname) { + return path.join(__dirname, '..', 'files', pathname) +} + +exports.GRAPH_PNG_HASH = '81dac49dc128aa0a7d0263d24c0d1ce14de554a8' +exports.GRAPH_PNG_BYTE_LENGTH = 13476 + +exports.HELLO_TXT_HASH = '80dc915a94d134320281f2a139c018facce4b670' +exports.HELLO_TXT_BYTE_LENGTH = 11 +exports.HELLO_TXT_UTF8_LENGTH = 10 + +// file is UTF-8 encoded and contains non BMP characters +exports.NON_BMP_TXT_HASH = '323ec6325a14288a81e15bc0bbee0c0a35f38049' +exports.NON_BMP_TXT_BYTE_LENGTH = 57 + +// files contains null characters +exports.NULL_CHARACTERS_TXT_HASH = '4227ca4e8736af63036e7457e2db376ddf7e5795' +exports.NULL_CHARACTERS_TXT_BYTE_LENGTH = 3 + +// git hashes of some short strings for testing +exports.STRING_A_HASH = '2e65efe2a145dda7ee51d1741299f848e5bf752e' +exports.STRING_AB_HASH = '9ae9e86b7bd6cb1472d9373702d8249973da0832' + +// From https://en.wikipedia.org/wiki/Portable_Network_Graphics +exports.PNG_MAGIC_NUMBER = '89504e470d0a1a0a' diff --git a/services/history-v1/test/acceptance/js/storage/support/unzip.js b/services/history-v1/test/acceptance/js/storage/support/unzip.js new file mode 100644 index 0000000000..c89c3d4e62 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/unzip.js @@ -0,0 +1,22 @@ +'use strict' + +const BPromise = require('bluebird') +const yauzl = BPromise.promisifyAll(require('yauzl')) + +function getZipEntries(pathname) { + function readEntries(zip) { + return new BPromise((resolve, reject) => { + const entries = [] + zip.on('entry', entry => { + entries.push(entry) + }) + zip.on('error', reject) + zip.on('end', () => { + resolve(entries) + }) + }) + } + return yauzl.openAsync(pathname).then(readEntries) +} + +exports.getZipEntries = getZipEntries diff --git a/services/history-v1/test/acceptance/js/storage/tasks.test.js b/services/history-v1/test/acceptance/js/storage/tasks.test.js new file mode 100644 index 0000000000..220c8f295f --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/tasks.test.js @@ -0,0 +1,111 @@ +'use strict' + +const { ObjectId } = require('mongodb') +const { expect } = require('chai') +const config = require('config') +const tasks = require('../../../../storage/tasks') +const { + persistor, + historyStore, + knex, + mongodb, +} = require('../../../../storage') +const cleanup = require('./support/cleanup') + +const CHUNK_STORE_BUCKET = config.get('chunkStore.bucket') +const postgresProjectId = 1 +const mongoProjectId = ObjectId('abcdefabcdefabcdefabcdef') + +describe('tasks', function () { + beforeEach(cleanup.everything) + + const options = { + batchSize: 3, + timeout: 3000, + minAgeSecs: 3600, + maxBatches: 1000, + } + + it('deletes old chunks', async function () { + const postgresChunks = [] + const mongoChunks = [] + + for (let i = 1; i <= 25; i++) { + const deletedAt = new Date(Date.now() - 86400000) + const startVersion = (i - 1) * 10 + const endVersion = i * 10 + postgresChunks.push({ + chunk_id: i, + doc_id: postgresProjectId, + start_version: startVersion, + end_version: endVersion, + deleted_at: deletedAt, + }) + mongoChunks.push({ + _id: ObjectId(i.toString().padStart(24, '0')), + projectId: mongoProjectId, + startVersion, + endVersion, + state: 'deleted', + updatedAt: deletedAt, + }) + } + + for (let i = 26; i <= 30; i++) { + const deletedAt = new Date() + const startVersion = (i - 1) * 10 + const endVersion = i * 10 + postgresChunks.push({ + chunk_id: i, + doc_id: postgresProjectId, + start_version: startVersion, + end_version: endVersion, + deleted_at: deletedAt, + }) + mongoChunks.push({ + _id: ObjectId(i.toString().padStart(24, '0')), + projectId: mongoProjectId, + startVersion, + endVersion, + state: 'deleted', + updatedAt: deletedAt, + }) + } + + await knex('old_chunks').insert(postgresChunks) + await mongodb.chunks.insertMany(mongoChunks) + await Promise.all([ + ...postgresChunks.map(chunk => + historyStore.storeRaw(postgresProjectId.toString(), chunk.chunk_id, { + history: 'raw history', + }) + ), + ...mongoChunks.map(chunk => + historyStore.storeRaw(mongoProjectId.toString(), chunk._id.toString(), { + history: 'raw history', + }) + ), + ]) + await expectChunksExist(1, 30, true) + await tasks.deleteOldChunks(options) + await expectChunksExist(1, 25, false) + await expectChunksExist(26, 30, true) + }) +}) + +async function expectChunksExist(minChunkId, maxChunkId, expected) { + const keys = [] + for (let i = minChunkId; i <= maxChunkId; i++) { + keys.push(`100/000/000/${i.toString().padStart(9, '0')}`) + keys.push(`fed/cba/fedcbafedcbafedcba/${i.toString().padStart(24, '0')}`) + } + return Promise.all( + keys.map(async key => { + const exists = await persistor.checkIfObjectExists( + CHUNK_STORE_BUCKET, + key + ) + expect(exists).to.equal(expected) + }) + ) +} diff --git a/services/history-v1/test/acceptance/js/storage/zip_store.test.js b/services/history-v1/test/acceptance/js/storage/zip_store.test.js new file mode 100644 index 0000000000..756873d5ad --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/zip_store.test.js @@ -0,0 +1,56 @@ +'use strict' + +const BPromise = require('bluebird') +const { expect } = require('chai') +const fs = BPromise.promisifyAll(require('fs')) +const temp = require('temp') + +const cleanup = require('./support/cleanup') +const fetch = require('./support/fetch') +const fixtures = require('./support/fixtures') +const { getZipEntries } = require('./support/unzip') + +const { Snapshot, File } = require('overleaf-editor-core') + +const { zipStore } = require('../../../../storage') + +describe('zipStore', function () { + beforeEach(cleanup.persistor) + + let zipFilePath + beforeEach(function () { + zipFilePath = temp.path({ suffix: '.zip' }) + }) + afterEach(async function () { + try { + await fs.unlinkAsync(zipFilePath) + } catch (_error) { + // Ignore. + } + }) + + it('stores a snapshot in a zip file', async function () { + const projectId = fixtures.docs.uninitializedProject.id + const version = 1 + const testSnapshot = new Snapshot() + testSnapshot.addFile('hello.txt', File.fromString('hello world')) + + const zipUrl = await zipStore.getSignedUrl(projectId, version) + + // Initially, there is no zip file; we should get a 404. + const preZipResponse = await fetch(zipUrl) + expect(preZipResponse.status).to.equal(404) + + // Build the zip file. + await zipStore.storeZip(projectId, version, testSnapshot) + + // Now we should be able to fetch it. + const postZipResponse = await fetch(zipUrl) + expect(postZipResponse.status).to.equal(200) + const zipBuffer = await postZipResponse.buffer() + await fs.writeFileAsync(zipFilePath, zipBuffer) + const entries = await getZipEntries(zipFilePath) + expect(entries.length).to.equal(1) + expect(entries[0].fileName).to.equal('hello.txt') + }) +}) diff --git a/services/history-v1/test/setup.js b/services/history-v1/test/setup.js new file mode 100644 index 0000000000..14f5ef0c85 --- /dev/null +++ b/services/history-v1/test/setup.js @@ -0,0 +1,58 @@ +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +const config = require('config') +const fetch = require('node-fetch') +const { knex, mongodb } = require('../storage') + +chai.use(chaiAsPromised) + +async function setupPostgresDatabase() { + await knex.migrate.latest() +} + +async function setupMongoDatabase() { + await mongodb.db.collection('projectHistoryChunks').createIndexes([ + { + key: { projectId: 1, startVersion: 1 }, + name: 'projectId_1_startVersion_1', + partialFilterExpression: { state: 'active' }, + unique: true, + }, + { + key: { state: 1 }, + name: 'state_1', + partialFilterExpression: { state: 'deleted' }, + }, + ]) +} + +async function createGcsBuckets() { + for (const bucket of [ + config.get('blobStore.globalBucket'), + config.get('blobStore.projectBucket'), + config.get('chunkStore.bucket'), + config.get('zipStore.bucket'), + ]) { + await fetch('http://gcs:9090/storage/v1/b', { + method: 'POST', + body: JSON.stringify({ name: bucket }), + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +// Tear down the connection pool after all the tests have run, so the process +// can exit. +async function tearDownConnectionPool() { + await knex.destroy() +} + +module.exports = { + setupPostgresDatabase, + createGcsBuckets, + tearDownConnectionPool, + mochaHooks: { + beforeAll: [setupPostgresDatabase, setupMongoDatabase, createGcsBuckets], + afterAll: [tearDownConnectionPool], + }, +} diff --git a/services/project-history/.eslintignore b/services/project-history/.eslintignore new file mode 100644 index 0000000000..8ac8c2dd51 --- /dev/null +++ b/services/project-history/.eslintignore @@ -0,0 +1 @@ +app/lib/*.js diff --git a/services/project-history/.github/ISSUE_TEMPLATE.md b/services/project-history/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..e0093aa90c --- /dev/null +++ b/services/project-history/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,38 @@ + + +## Steps to Reproduce + + + +1. +2. +3. + +## Expected Behaviour + + +## Observed Behaviour + + + +## Context + + +## Technical Info + + +* URL: +* Browser Name and version: +* Operating System and version (desktop or mobile): +* Signed in as: +* Project and/or file: + +## Analysis + + +## Who Needs to Know? + + + +- +- diff --git a/services/project-history/.github/PULL_REQUEST_TEMPLATE.md b/services/project-history/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..0d4ce514d8 --- /dev/null +++ b/services/project-history/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,46 @@ + + + +### Description + + + +#### Screenshots + + + +#### Related Issues / PRs + + + +### Review + + + +#### Potential Impact + + + +#### Manual Testing Performed + +- [ ] +- [ ] + +#### Accessibility + + + +### Deployment + + + +#### Deployment Checklist + +- [ ] Update documentation not included in the PR (if any) +- [ ] + +#### Metrics and Monitoring + + + +#### Who Needs to Know? diff --git a/services/project-history/.gitignore b/services/project-history/.gitignore new file mode 100644 index 0000000000..25328fed2e --- /dev/null +++ b/services/project-history/.gitignore @@ -0,0 +1,8 @@ +**.swp +node_modules/ +forever/ +.config +.npm + +# managed by dev-environment$ bin/update_build_scripts +.npmrc diff --git a/services/project-history/.mocharc.json b/services/project-history/.mocharc.json new file mode 100644 index 0000000000..dc3280aa96 --- /dev/null +++ b/services/project-history/.mocharc.json @@ -0,0 +1,3 @@ +{ + "require": "test/setup.js" +} diff --git a/services/project-history/.nvmrc b/services/project-history/.nvmrc new file mode 100644 index 0000000000..c85fa1bbef --- /dev/null +++ b/services/project-history/.nvmrc @@ -0,0 +1 @@ +16.17.1 diff --git a/services/project-history/Dockerfile b/services/project-history/Dockerfile new file mode 100644 index 0000000000..238604caec --- /dev/null +++ b/services/project-history/Dockerfile @@ -0,0 +1,26 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +FROM node:16.17.1 as base + +WORKDIR /overleaf/services/project-history + +# Google Cloud Storage needs a writable $HOME/.config for resumable uploads +# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) +RUN mkdir /home/node/.config && chown node:node /home/node/.config + +FROM base as app + +COPY package.json package-lock.json /overleaf/ +COPY services/project-history/package.json /overleaf/services/project-history/ +COPY libraries/ /overleaf/libraries/ + +RUN cd /overleaf && npm ci --quiet + +COPY services/project-history/ /overleaf/services/project-history/ + +FROM app +USER node + +CMD ["node", "--expose-gc", "app.js"] diff --git a/services/project-history/Makefile b/services/project-history/Makefile new file mode 100644 index 0000000000..dcf26456ca --- /dev/null +++ b/services/project-history/Makefile @@ -0,0 +1,100 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = project-history +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') + +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ + BRANCH_NAME=$(BRANCH_NAME) \ + PROJECT_NAME=$(PROJECT_NAME) \ + MOCHA_GREP=${MOCHA_GREP} \ + docker-compose ${DOCKER_COMPOSE_FLAGS} + +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=test_acceptance_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=test_unit_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +clean: + -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local + -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local + +HERE=$(shell pwd) +MONOREPO=$(shell cd ../../ && pwd) +# Run the linting commands in the scope of the monorepo. +# Eslint and prettier (plus some configs) are on the root. +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:16.17.1 npm run --silent + +format: + $(RUN_LINTING) format + +format_fix: + $(RUN_LINTING) format:fix + +lint: + $(RUN_LINTING) lint + +lint_fix: + $(RUN_LINTING) lint:fix + +test: format lint test_unit test_acceptance + +test_unit: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit + $(MAKE) test_unit_clean +endif + +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif + +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean + +test_acceptance_run: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif + +test_acceptance_run_debug: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif + +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 + +test_acceptance_pre_run: +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif + +build: + docker build --pull --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --file Dockerfile \ + ../.. + +tar: + $(DOCKER_COMPOSE) up tar + +publish: + + docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + + +.PHONY: clean test test_unit test_acceptance test_clean build publish diff --git a/services/project-history/README.md b/services/project-history/README.md new file mode 100644 index 0000000000..9d511926cd --- /dev/null +++ b/services/project-history/README.md @@ -0,0 +1,71 @@ +@overleaf/project-history +========================== + +An API for converting raw editor updates into a compressed and browseable history. + +Running project-history +----------------------- + +The app runs natively using npm and Node on the local system: + +``` +npm install +npm run start +``` + +Unit Tests +---------- + +The test suites run in Docker. + +Unit tests can be run in the `test_unit` container defined in `docker-compose.tests.yml`. + +The makefile contains a short cut to run these: + +``` +make install # Only needs running once, or when npm packages are updated +make test_unit +``` + +During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI: + +``` +make test_unit MOCHA_ARGS='--grep=AuthorizationManager' +``` + +Acceptance Tests +---------------- + +Acceptance tests are run against a live service, which runs in the `acceptance_test` container defined in `docker-compose.tests.yml`. + +To run the tests out-of-the-box, the makefile defines: + +``` +make install # Only needs running once, or when npm packages are updated +make test_acceptance +``` + +However, during development it is often useful to leave the service running for rapid iteration on the acceptance tests. This can be done with: + +``` +make test_acceptance_start_service +make test_acceptance_run # Run as many times as needed during development +make test_acceptance_stop_service +``` + +`make test_acceptance` just runs these three commands in sequence. + +During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI: + +``` +make test_acceptance_run MOCHA_ARGS='--grep=AuthorizationManager' +``` + +Makefile and npm scripts +------------------------ + +The commands used to compile the app and tests, to run the mocha tests, and to run the app are all in `package.json`. These commands call out to `coffee`, `mocha`, etc which are available to `npm` in the local `node_modules/.bin` directory, using the local versions. Normally, these commands should not be run directly, but instead run in docker via make. + +The makefile contains a collection of shortcuts for running the npm scripts inside the appropriate docker containers, using the `docker-compose` files in the project. + +Copyright (c) Overleaf, 2017-2021. diff --git a/services/project-history/app.js b/services/project-history/app.js new file mode 100644 index 0000000000..bd4f7fae05 --- /dev/null +++ b/services/project-history/app.js @@ -0,0 +1,24 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import { mongoClient } from './app/js/mongodb.js' +import { app } from './app/js/server.js' + +const host = Settings.internal.history.host +const port = Settings.internal.history.port + +mongoClient + .connect() + .then(() => { + app.listen(port, host, error => { + if (error != null) { + logger.error(OError.tag(error, 'could not start history server')) + } else { + logger.debug(`history starting up, listening on ${host}:${port}`) + } + }) + }) + .catch(err => { + logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') + process.exit(1) + }) diff --git a/services/project-history/app/js/BlobManager.js b/services/project-history/app/js/BlobManager.js new file mode 100644 index 0000000000..245272a9de --- /dev/null +++ b/services/project-history/app/js/BlobManager.js @@ -0,0 +1,113 @@ +import async from 'async' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as UpdateTranslator from './UpdateTranslator.js' + +// avoid creating too many blobs at the same time +const MAX_CONCURRENT_REQUESTS = 4 +// number of retry attempts for blob creation +const RETRY_ATTEMPTS = 3 +// delay between retries +const RETRY_INTERVAL = 100 + +export function createBlobsForUpdates( + projectId, + historyId, + updates, + extendLock, + callback +) { + // async.mapLimit runs jobs in parallel and returns on the first error. It + // doesn't wait for concurrent jobs to finish. We want to make sure all jobs + // are wrapped within our lock so we collect the first error enountered here + // and wait for all jobs to finish before returning the error. + let firstBlobCreationError = null + + function createBlobForUpdate(update, cb) { + // For file additions we need to first create a blob in the history-store + // with the contents of the file. Then we can create a change containing a + // file addition operation which references the blob. + // + // To do this we decorate file creation updates with a blobHash + if (!UpdateTranslator.isAddUpdate(update)) { + return async.setImmediate(() => cb(null, { update })) + } + + let attempts = 0 + // Since we may be creating O(1000) blobs in an update, allow for the + // occasional failure to prevent the whole update failing. + async.retry( + { + times: RETRY_ATTEMPTS, + interval: RETRY_INTERVAL, + }, + _cb => { + attempts++ + if (attempts > 1) { + logger.error( + { projectId, doc: update.doc, file: update.file, attempts }, + 'previous createBlob attempt failed, retrying' + ) + } + // extend the lock for each file because large files may take a long time + extendLock(err => { + if (err) { + return _cb(OError.tag(err)) + } + HistoryStoreManager.createBlobForUpdate( + projectId, + historyId, + update, + (err, hash) => { + if (err) { + OError.tag(err, 'retry: error creating blob', { + projectId, + doc: update.doc, + file: update.file, + }) + _cb(err) + } else { + _cb(null, hash) + } + } + ) + }) + }, + (error, blobHash) => { + if (error) { + if (!firstBlobCreationError) { + firstBlobCreationError = error + } + return cb(null, { update, blobHash }) + } + + extendLock(error => { + if (error) { + if (!firstBlobCreationError) { + firstBlobCreationError = error + } + } + cb(null, { update, blobHash }) + }) + } + ) + } + + async.mapLimit( + updates, + MAX_CONCURRENT_REQUESTS, + createBlobForUpdate, + (unusedError, updatesWithBlobs) => { + // As indicated by the name this is unexpected, but changes in the future + // could cause it to be set and ignoring it would be unexpected + if (unusedError) { + return callback(unusedError) + } + if (firstBlobCreationError) { + return callback(firstBlobCreationError) + } + callback(null, updatesWithBlobs) + } + ) +} diff --git a/services/project-history/app/js/ChunkTranslator.js b/services/project-history/app/js/ChunkTranslator.js new file mode 100644 index 0000000000..68f28d0da0 --- /dev/null +++ b/services/project-history/app/js/ChunkTranslator.js @@ -0,0 +1,389 @@ +import _ from 'lodash' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as WebApiManager from './WebApiManager.js' +import * as Errors from './Errors.js' + +export function convertToSummarizedUpdates(chunk, callback) { + const version = chunk.chunk.startVersion + const { files } = chunk.chunk.history.snapshot + const builder = new UpdateSetBuilder(version, files) + + for (const change of chunk.chunk.history.changes) { + try { + builder.applyChange(change) + } catch (error1) { + const error = error1 + return callback(error) + } + } + callback(null, builder.summarizedUpdates) +} + +export function convertToDiffUpdates( + projectId, + chunk, + pathname, + fromVersion, + toVersion, + callback +) { + let error + let version = chunk.chunk.startVersion + const { files } = chunk.chunk.history.snapshot + const builder = new UpdateSetBuilder(version, files) + + let file = null + for (const change of chunk.chunk.history.changes) { + // Because we're referencing by pathname, which can change, we + // want to get the first file in the range fromVersion:toVersion + // that has the pathname we want. Note that this might not exist yet + // at fromVersion, so we'll just settle for the first one we find + // after that. + if (fromVersion <= version && version <= toVersion) { + if (file == null) { + file = builder.getFile(pathname) + } + } + + try { + builder.applyChange(change) + } catch (error1) { + error = error1 + return callback(error) + } + version += 1 + } + // Versions act as fence posts, with updates taking us from one to another, + // so we also need to check after the final update, when we're at the last version. + if (fromVersion <= version && version <= toVersion) { + if (file == null) { + file = builder.getFile(pathname) + } + } + + // return an empty diff if the file was flagged as missing with an explicit null + if (builder.getFile(pathname) === null) { + return callback(null, { initialContent: '', updates: [] }) + } + + if (file == null) { + error = new Errors.NotFoundError( + `pathname '${pathname}' not found in range` + ) + return callback(error) + } + + WebApiManager.getHistoryId(projectId, (err, historyId) => { + if (err) { + return callback(err) + } + file.getDiffUpdates(historyId, fromVersion, toVersion, callback) + }) +} + +class UpdateSetBuilder { + constructor(startVersion, files) { + this.version = startVersion + this.summarizedUpdates = [] + + this.files = Object.create(null) + for (const pathname in files) { + // initialize file from snapshot + const data = files[pathname] + this.files[pathname] = new File(pathname, data, startVersion) + } + } + + getFile(pathname) { + return this.files[pathname] + } + + applyChange(change) { + const timestamp = new Date(change.timestamp) + let authors = _.map(change.authors, id => { + if (id == null) { + return null + } + return id + }) + authors = authors.concat(change.v2Authors || []) + this.currentUpdate = { + meta: { + users: authors, + start_ts: timestamp.getTime(), + end_ts: timestamp.getTime(), + }, + v: this.version, + pathnames: new Set([]), + project_ops: [], + } + if (change.origin) { + this.currentUpdate.meta.origin = change.origin + } + + for (const op of change.operations) { + this.applyOperation(op, timestamp, authors) + } + + this.currentUpdate.pathnames = Array.from(this.currentUpdate.pathnames) + this.summarizedUpdates.push(this.currentUpdate) + + this.version += 1 + } + + applyOperation(op, timestamp, authors) { + if (UpdateSetBuilder._isTextOperation(op)) { + this.applyTextOperation(op, timestamp, authors) + } else if (UpdateSetBuilder._isRenameOperation(op)) { + this.applyRenameOperation(op, timestamp, authors) + } else if (UpdateSetBuilder._isRemoveFileOperation(op)) { + this.applyRemoveFileOperation(op, timestamp, authors) + } else if (UpdateSetBuilder._isAddFileOperation(op)) { + this.applyAddFileOperation(op, timestamp, authors) + } + } + + applyTextOperation(operation, timestamp, authors) { + const { pathname } = operation + if (pathname === '') { + // this shouldn't happen, but we continue to allow the user to see the history + logger.warn( + { operation, timestamp, authors }, + 'pathname is empty for text operation' + ) + return + } + + const file = this.files[pathname] + if (file == null) { + // this shouldn't happen, but we continue to allow the user to see the history + logger.warn( + { operation, timestamp, authors }, + 'file is missing for text operation' + ) + this.files[pathname] = null // marker for a missing file + return + } + + file.applyTextOperation(authors, timestamp, this.version, operation) + this.currentUpdate.pathnames.add(pathname) + } + + applyRenameOperation(operation, timestamp, authors) { + const { pathname, newPathname } = operation + const file = this.files[pathname] + if (file == null) { + // this shouldn't happen, but we continue to allow the user to see the history + logger.warn( + { operation, timestamp, authors }, + 'file is missing for rename operation' + ) + this.files[pathname] = null // marker for a missing file + return + } + + file.rename(newPathname) + delete this.files[pathname] + this.files[newPathname] = file + + this.currentUpdate.project_ops.push({ + rename: { pathname, newPathname }, + }) + } + + applyAddFileOperation(operation, timestamp, authors) { + const { pathname } = operation + // add file + this.files[pathname] = new File(pathname, operation.file, this.version) + + this.currentUpdate.project_ops.push({ add: { pathname } }) + } + + applyRemoveFileOperation(operation, timestamp, authors) { + const { pathname } = operation + const file = this.files[pathname] + if (file == null) { + // this shouldn't happen, but we continue to allow the user to see the history + logger.warn( + { operation, timestamp, authors }, + 'pathname not found when removing file' + ) + this.files[pathname] = null // marker for a missing file + return + } + + delete this.files[pathname] + + this.currentUpdate.project_ops.push({ remove: { pathname } }) + } + + static _isTextOperation(op) { + return Object.prototype.hasOwnProperty.call(op, 'textOperation') + } + + static _isRenameOperation(op) { + return ( + Object.prototype.hasOwnProperty.call(op, 'newPathname') && + op.newPathname !== '' + ) + } + + static _isRemoveFileOperation(op) { + return ( + Object.prototype.hasOwnProperty.call(op, 'newPathname') && + op.newPathname === '' + ) + } + + static _isAddFileOperation(op) { + return Object.prototype.hasOwnProperty.call(op, 'file') + } +} + +class File { + constructor(pathname, snapshot, initialVersion) { + this.pathname = pathname + this.snapshot = snapshot + this.initialVersion = initialVersion + this.operations = [] + } + + applyTextOperation(authors, timestamp, version, operation) { + this.operations.push({ authors, timestamp, version, operation }) + } + + rename(pathname) { + this.pathname = pathname + } + + getDiffUpdates(historyId, fromVersion, toVersion, callback) { + if (this.snapshot.stringLength == null) { + // Binary file + return callback(null, { binary: true }) + } + HistoryStoreManager.getProjectBlob( + historyId, + this.snapshot.hash, + (error, content) => { + if (error != null) { + return callback(OError.tag(error)) + } + let initialContent = content + const updates = [] + for (let operation of this.operations) { + let authors, ops, timestamp, version + ;({ authors, timestamp, version, operation } = operation) + ;({ content, ops } = this._convertTextOperation(content, operation)) + + // Keep updating our initialContent, until we're actually in the version + // we want to diff, at which point initialContent is the content just before + // the diff updates we will return + if (version < fromVersion) { + initialContent = content + } + + // We only need to return the updates between fromVersion and toVersion + if (fromVersion <= version && version < toVersion) { + updates.push({ + meta: { + users: authors, + start_ts: timestamp.getTime(), + end_ts: timestamp.getTime(), + }, + v: version, + op: ops, + }) + } + } + + callback(null, { initialContent, updates }) + } + ) + } + + _convertTextOperation(content, operation) { + const textUpdateBuilder = new TextUpdateBuilder(content) + for (const op of operation.textOperation || []) { + textUpdateBuilder.applyOp(op) + } + textUpdateBuilder.finish() + return { + content: textUpdateBuilder.result, + ops: textUpdateBuilder.changes, + } + } +} + +class TextUpdateBuilder { + constructor(source) { + this.source = source + this.sourceCursor = 0 + this.result = '' + this.changes = [] + } + + applyOp(op) { + if (TextUpdateBuilder._isRetainOperation(op)) { + this.applyRetain(op) + } + + if (TextUpdateBuilder._isInsertOperation(op)) { + this.applyInsert(op) + } + + if (TextUpdateBuilder._isDeleteOperation(op)) { + this.applyDelete(-op) + } + } + + applyRetain(offset) { + this.result += this.source.slice( + this.sourceCursor, + this.sourceCursor + offset + ) + this.sourceCursor += offset + } + + applyInsert(content) { + this.changes.push({ + i: content, + p: this.result.length, + }) + this.result += content + // The source cursor doesn't advance + } + + applyDelete(offset) { + const deletedContent = this.source.slice( + this.sourceCursor, + this.sourceCursor + offset + ) + + this.changes.push({ + d: deletedContent, + p: this.result.length, + }) + + this.sourceCursor += offset + } + + finish() { + if (this.sourceCursor < this.source.length) { + this.result += this.source.slice(this.sourceCursor) + } + } + + static _isRetainOperation(op) { + return typeof op === 'number' && op > 0 + } + + static _isInsertOperation(op) { + return typeof op === 'string' + } + + static _isDeleteOperation(op) { + return typeof op === 'number' && op < 0 + } +} diff --git a/services/project-history/app/js/DiffGenerator.js b/services/project-history/app/js/DiffGenerator.js new file mode 100644 index 0000000000..7f698202d4 --- /dev/null +++ b/services/project-history/app/js/DiffGenerator.js @@ -0,0 +1,273 @@ +/* eslint-disable + no-proto, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ + +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' + +export class ConsistencyError extends OError {} + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +export function buildDiff(initialContent, updates) { + let diff = [{ u: initialContent }] + for (const update of Array.from(updates)) { + diff = applyUpdateToDiff(diff, update) + } + diff = compressDiff(diff) + return diff +} + +_mocks.compressDiff = diff => { + const newDiff = [] + for (const part of Array.from(diff)) { + const lastPart = newDiff[newDiff.length - 1] + if ( + lastPart != null && + (lastPart.meta != null ? lastPart.meta.user : undefined) != null && + (part.meta != null ? part.meta.user : undefined) != null + ) { + if ( + lastPart.i != null && + part.i != null && + lastPart.meta.user.id === part.meta.user.id + ) { + lastPart.i += part.i + lastPart.meta.start_ts = Math.min( + lastPart.meta.start_ts, + part.meta.start_ts + ) + lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) + } else if ( + lastPart.d != null && + part.d != null && + lastPart.meta.user.id === part.meta.user.id + ) { + lastPart.d += part.d + lastPart.meta.start_ts = Math.min( + lastPart.meta.start_ts, + part.meta.start_ts + ) + lastPart.meta.end_ts = Math.max(lastPart.meta.end_ts, part.meta.end_ts) + } else { + newDiff.push(part) + } + } else { + newDiff.push(part) + } + } + return newDiff +} + +export function compressDiff(...args) { + return _mocks.compressDiff(...args) +} + +export function applyOpToDiff(diff, op, meta) { + let consumedDiff + const position = 0 + + let remainingDiff = diff.slice() + ;({ consumedDiff, remainingDiff } = _consumeToOffset(remainingDiff, op.p)) + const newDiff = consumedDiff + + if (op.i != null) { + newDiff.push({ + i: op.i, + meta, + }) + } else if (op.d != null) { + ;({ consumedDiff, remainingDiff } = _consumeDiffAffectedByDeleteOp( + remainingDiff, + op, + meta + )) + newDiff.push(...Array.from(consumedDiff || [])) + } + + newDiff.push(...Array.from(remainingDiff || [])) + + return newDiff +} + +_mocks.applyUpdateToDiff = (diff, update) => { + for (const op of Array.from(update.op)) { + if (op.broken !== true) { + diff = applyOpToDiff(diff, op, update.meta) + } + } + return diff +} + +export function applyUpdateToDiff(...args) { + return _mocks.applyUpdateToDiff(...args) +} + +function _consumeToOffset(remainingDiff, totalOffset) { + let part + const consumedDiff = [] + let position = 0 + while ((part = remainingDiff.shift())) { + const length = _getLengthOfDiffPart(part) + if (part.d != null) { + consumedDiff.push(part) + } else if (position + length >= totalOffset) { + const partOffset = totalOffset - position + if (partOffset > 0) { + consumedDiff.push(_slicePart(part, 0, partOffset)) + } + if (partOffset < length) { + remainingDiff.unshift(_slicePart(part, partOffset)) + } + break + } else { + position += length + consumedDiff.push(part) + } + } + + return { + consumedDiff, + remainingDiff, + } +} + +function _consumeDiffAffectedByDeleteOp(remainingDiff, deleteOp, meta) { + const consumedDiff = [] + let remainingOp = deleteOp + while (remainingOp && remainingDiff.length > 0) { + let newPart + ;({ newPart, remainingDiff, remainingOp } = _consumeDeletedPart( + remainingDiff, + remainingOp, + meta + )) + if (newPart != null) { + consumedDiff.push(newPart) + } + } + return { + consumedDiff, + remainingDiff, + } +} + +function _consumeDeletedPart(remainingDiff, op, meta) { + let deletedContent, newPart, remainingOp + const part = remainingDiff.shift() + const partLength = _getLengthOfDiffPart(part) + + if (part.d != null) { + // Skip existing deletes + remainingOp = op + newPart = part + } else if (partLength > op.d.length) { + // Only the first bit of the part has been deleted + const remainingPart = _slicePart(part, op.d.length) + remainingDiff.unshift(remainingPart) + + deletedContent = _getContentOfPart(part).slice(0, op.d.length) + if (deletedContent !== op.d) { + throw new ConsistencyError( + `deleted content, '${deletedContent}', does not match delete op, '${op.d}'` + ) + } + + if (part.u != null) { + newPart = { + d: op.d, + meta, + } + } else if (part.i != null) { + newPart = null + } + + remainingOp = null + } else if (partLength === op.d.length) { + // The entire part has been deleted, but it is the last part + + deletedContent = _getContentOfPart(part) + if (deletedContent !== op.d) { + throw new ConsistencyError( + `deleted content, '${deletedContent}', does not match delete op, '${op.d}'` + ) + } + + if (part.u != null) { + newPart = { + d: op.d, + meta, + } + } else if (part.i != null) { + newPart = null + } + + remainingOp = null + } else if (partLength < op.d.length) { + // The entire part has been deleted and there is more + + deletedContent = _getContentOfPart(part) + const opContent = op.d.slice(0, deletedContent.length) + if (deletedContent !== opContent) { + throw new ConsistencyError( + `deleted content, '${deletedContent}', does not match delete op, '${opContent}'` + ) + } + + if (part.u) { + newPart = { + d: part.u, + meta, + } + } else if (part.i != null) { + newPart = null + } + + remainingOp = { + p: op.p, + d: op.d.slice(_getLengthOfDiffPart(part)), + } + } + + return { + newPart, + remainingDiff, + remainingOp, + } +} + +function _slicePart(basePart, from, to) { + let part + if (basePart.u != null) { + part = { u: basePart.u.slice(from, to) } + } else if (basePart.i != null) { + part = { i: basePart.i.slice(from, to) } + } + if (basePart.meta != null) { + part.meta = basePart.meta + } + return part +} + +function _getLengthOfDiffPart(part) { + return (part.u || part.d || part.i || '').length +} + +function _getContentOfPart(part) { + return part.u || part.d || part.i || '' +} diff --git a/services/project-history/app/js/DiffManager.js b/services/project-history/app/js/DiffManager.js new file mode 100644 index 0000000000..80e80f9351 --- /dev/null +++ b/services/project-history/app/js/DiffManager.js @@ -0,0 +1,229 @@ +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import async from 'async' +import * as DiffGenerator from './DiffGenerator.js' +import * as FileTreeDiffGenerator from './FileTreeDiffGenerator.js' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as WebApiManager from './WebApiManager.js' +import * as ChunkTranslator from './ChunkTranslator.js' +import * as Errors from './Errors.js' + +let MAX_CHUNK_REQUESTS = 5 + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +export function getDiff(projectId, pathname, fromVersion, toVersion, callback) { + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error) { + return callback(OError.tag(error)) + } + _getProjectUpdatesBetweenVersions( + projectId, + pathname, + fromVersion, + toVersion, + (error, result) => { + if (error) { + return callback(OError.tag(error)) + } + const { binary, initialContent, updates } = result + let diff + if (binary) { + diff = { binary: true } + } else { + diff = DiffGenerator.buildDiff(initialContent, updates) + } + callback(null, diff) + } + ) + }) +} + +export function getFileTreeDiff(projectId, fromVersion, toVersion, callback) { + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error) { + return callback(OError.tag(error)) + } + _getChunksAsSingleChunk( + projectId, + fromVersion, + toVersion, + (error, chunk) => { + let diff + if (error) { + return callback(OError.tag(error)) + } + try { + diff = FileTreeDiffGenerator.buildDiff(chunk, fromVersion, toVersion) + } catch (error1) { + error = error1 + if (error instanceof Errors.InconsistentChunkError) { + return callback(error) + } else { + throw OError.tag(error) + } + } + callback(null, diff) + } + ) + }) +} + +export function _getChunksAsSingleChunk( + projectId, + fromVersion, + toVersion, + callback +) { + logger.debug( + { projectId, fromVersion, toVersion }, + '[_getChunksAsSingleChunk] getting chunks' + ) + _getChunks(projectId, fromVersion, toVersion, (error, chunks) => { + if (error) { + return callback(OError.tag(error)) + } + logger.debug( + { projectId, fromVersion, toVersion, chunks }, + '[_getChunksAsSingleChunk] got chunks' + ) + const chunk = _concatChunks(chunks) + callback(null, chunk) + }) +} + +_mocks._getProjectUpdatesBetweenVersions = ( + projectId, + pathname, + fromVersion, + toVersion, + callback +) => { + _getChunksAsSingleChunk(projectId, fromVersion, toVersion, (error, chunk) => { + if (error) { + return callback(OError.tag(error)) + } + logger.debug( + { projectId, pathname, fromVersion, toVersion, chunk }, + '[_getProjectUpdatesBetweenVersions] concatted chunk' + ) + ChunkTranslator.convertToDiffUpdates( + projectId, + chunk, + pathname, + fromVersion, + toVersion, + callback + ) + }) +} + +export function _getProjectUpdatesBetweenVersions(...args) { + _mocks._getProjectUpdatesBetweenVersions(...args) +} + +_mocks._getChunks = (projectId, fromVersion, toVersion, callback) => { + let chunksRequested = 0 + let lastChunkStartVersion = toVersion + const chunks = [] + + function shouldRequestAnotherChunk(cb) { + const stillUnderChunkLimit = chunksRequested < MAX_CHUNK_REQUESTS + const stillNeedVersions = fromVersion < lastChunkStartVersion + const stillSaneStartVersion = lastChunkStartVersion > 0 + logger.debug( + { + projectId, + stillUnderChunkLimit, + stillNeedVersions, + stillSaneStartVersion, + fromVersion, + lastChunkStartVersion, + chunksRequested, + }, + '[_getChunks.shouldRequestAnotherChunk]' + ) + return cb( + null, + stillUnderChunkLimit && stillNeedVersions && stillSaneStartVersion + ) + } + + function getNextChunk(cb) { + logger.debug( + { + projectId, + lastChunkStartVersion, + }, + '[_getChunks.getNextChunk]' + ) + WebApiManager.getHistoryId(projectId, (error, historyId) => { + if (error) { + return cb(OError.tag(error)) + } + HistoryStoreManager.getChunkAtVersion( + projectId, + historyId, + lastChunkStartVersion, + (error, chunk) => { + if (error) { + return cb(OError.tag(error)) + } + lastChunkStartVersion = chunk.chunk.startVersion + chunksRequested += 1 + chunks.push(chunk) + cb() + } + ) + }) + } + + getNextChunk(error => { + if (error) { + return callback(OError.tag(error)) + } + async.whilst(shouldRequestAnotherChunk, getNextChunk, error => { + if (error) { + return callback(error) + } + if (chunksRequested >= MAX_CHUNK_REQUESTS) { + error = new Errors.BadRequestError('Diff spans too many chunks') + callback(error) + } else { + callback(null, chunks) + } + }) + }) +} + +export function _getChunks(...args) { + _mocks._getChunks(...args) +} + +_mocks._concatChunks = chunks => { + chunks.reverse() + const chunk = chunks[0] + // We will append all of the changes from the later + // chunks onto the first one, to form one 'big' chunk. + for (const nextChunk of chunks.slice(1)) { + chunk.chunk.history.changes = chunk.chunk.history.changes.concat( + nextChunk.chunk.history.changes + ) + } + return chunk +} + +function _concatChunks(...args) { + return _mocks._concatChunks(...args) +} + +// for tests +export function setMaxChunkRequests(value) { + MAX_CHUNK_REQUESTS = value +} diff --git a/services/project-history/app/js/DocumentUpdaterManager.js b/services/project-history/app/js/DocumentUpdaterManager.js new file mode 100644 index 0000000000..def936e54d --- /dev/null +++ b/services/project-history/app/js/DocumentUpdaterManager.js @@ -0,0 +1,81 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import request from 'request' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import OError from '@overleaf/o-error' + +export function getDocument(project_id, doc_id, callback) { + if (callback == null) { + callback = function () {} + } + const url = `${Settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}` + logger.debug({ project_id, doc_id }, 'getting doc from document updater') + return request.get(url, function (error, res, body) { + if (error != null) { + return callback(OError.tag(error)) + } + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + body = JSON.parse(body) + } catch (error1) { + error = error1 + return callback(error) + } + logger.debug( + { project_id, doc_id, version: body.version }, + 'got doc from document updater' + ) + return callback(null, body.lines.join('\n'), body.version) + } else { + error = new OError( + `doc updater returned a non-success status code: ${res.statusCode}`, + { project_id, doc_id, url } + ) + return callback(error) + } + }) +} + +export function setDocument(project_id, doc_id, content, user_id, callback) { + if (callback == null) { + callback = function () {} + } + const url = `${Settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}` + logger.debug({ project_id, doc_id }, 'setting doc in document updater') + return request.post( + { + url, + json: { + lines: content.split('\n'), + source: 'restore', + user_id, + undoing: true, + }, + }, + function (error, res, body) { + if (error != null) { + return callback(OError.tag(error)) + } + if (res.statusCode >= 200 && res.statusCode < 300) { + return callback(null) + } else { + error = new OError( + `doc updater returned a non-success status code: ${res.statusCode}`, + { project_id, doc_id, url } + ) + return callback(error) + } + } + ) +} diff --git a/services/project-history/app/js/ErrorRecorder.js b/services/project-history/app/js/ErrorRecorder.js new file mode 100644 index 0000000000..28b626ba14 --- /dev/null +++ b/services/project-history/app/js/ErrorRecorder.js @@ -0,0 +1,308 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { promisify } from 'util' +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' +import { db } from './mongodb.js' + +export function record(project_id, queueSize, error, callback) { + if (callback == null) { + callback = function () {} + } + const _callback = function (mongoError) { + if (mongoError != null) { + logger.error( + { project_id, mongoError }, + 'failed to change project statues in mongo' + ) + } + return callback(error || null, queueSize) + } + + if (error != null) { + const errorRecord = { + queueSize, + error: error.toString(), + stack: error.stack, + ts: new Date(), + } + logger.debug( + { project_id, errorRecord }, + 'recording failed attempt to process updates' + ) + return db.projectHistoryFailures.updateOne( + { + project_id, + }, + { + $set: errorRecord, + $inc: { + attempts: 1, + }, + $push: { + history: { + $each: [errorRecord], + $position: 0, + $slice: 10, + }, + }, // only keep recent failures + }, + { + upsert: true, + }, + _callback + ) + } else { + return db.projectHistoryFailures.deleteOne({ project_id }, _callback) + } +} + +export function setForceDebug(project_id, state, callback) { + if (state == null) { + state = true + } + if (callback == null) { + callback = function () {} + } + logger.debug({ project_id, state }, 'setting forceDebug state for project') + return db.projectHistoryFailures.updateOne( + { project_id }, + { $set: { forceDebug: state } }, + { upsert: true }, + callback + ) +} + +// we only record the sync start time, and not the end time, because the +// record should be cleared on success. +export function recordSyncStart(project_id, callback) { + if (callback == null) { + callback = function () {} + } + return db.projectHistoryFailures.updateOne( + { + project_id, + }, + { + $currentDate: { + resyncStartedAt: true, + }, + $inc: { + resyncAttempts: 1, + }, + $push: { + history: { + $each: [{ resyncStartedAt: new Date() }], + $position: 0, + $slice: 10, + }, + }, + }, + { + upsert: true, + }, + callback + ) +} + +export function getFailureRecord(project_id, callback) { + if (callback == null) { + callback = function () {} + } + return db.projectHistoryFailures.findOne({ project_id }, callback) +} + +export function getLastFailure(project_id, callback) { + if (callback == null) { + callback = function () {} + } + return db.projectHistoryFailures.findOneAndUpdate( + { project_id }, + { $inc: { requestCount: 1 } }, // increment the request count every time we check the last failure + { projection: { error: 1, ts: 1 } }, + (err, result) => callback(err, result && result.value) + ) +} + +export function getFailedProjects(callback) { + if (callback == null) { + callback = function () {} + } + return db.projectHistoryFailures.find({}).toArray(function (error, results) { + if (error != null) { + return callback(OError.tag(error)) + } + return callback(null, results) + }) +} + +export function getFailuresByType(callback) { + if (callback == null) { + callback = function () {} + } + db.projectHistoryFailures.find({}).toArray(function (error, results) { + if (error != null) { + return callback(OError.tag(error)) + } + const failureCounts = {} + const failureAttempts = {} + const failureRequests = {} + const maxQueueSize = {} + // count all the failures and number of attempts by type + for (const result of Array.from(results || [])) { + const failureType = result.error + const attempts = result.attempts || 1 // allow for field to be absent + const requests = result.requestCount || 0 + const queueSize = result.queueSize || 0 + if (failureCounts[failureType] > 0) { + failureCounts[failureType]++ + failureAttempts[failureType] += attempts + failureRequests[failureType] += requests + maxQueueSize[failureType] = Math.max( + queueSize, + maxQueueSize[failureType] + ) + } else { + failureCounts[failureType] = 1 + failureAttempts[failureType] = attempts + failureRequests[failureType] = requests + maxQueueSize[failureType] = queueSize + } + } + return callback( + null, + failureCounts, + failureAttempts, + failureRequests, + maxQueueSize + ) + }) +} + +export function getFailures(callback) { + if (callback == null) { + callback = function () {} + } + return getFailuresByType(function ( + error, + failureCounts, + failureAttempts, + failureRequests, + maxQueueSize + ) { + let attempts, failureType, label, requests + if (error != null) { + return callback(OError.tag(error)) + } + + const shortNames = { + 'Error: bad response from filestore: 404': 'filestore-404', + 'Error: bad response from filestore: 500': 'filestore-500', + 'NotFoundError: got a 404 from web api': 'web-api-404', + 'Error: history store a non-success status code: 413': + 'history-store-413', + 'Error: history store a non-success status code: 422': + 'history-store-422', + 'Error: history store a non-success status code: 500': + 'history-store-500', + 'Error: history store a non-success status code: 503': + 'history-store-503', + 'Error: web returned a non-success status code: 500 (attempts: 2)': + 'web-500', + 'Error: ESOCKETTIMEDOUT': 'socket-timeout', + 'Error: no project found': 'no-project-found', + 'OpsOutOfOrderError: project structure version out of order on incoming updates': + 'incoming-project-version-out-of-order', + 'OpsOutOfOrderError: doc version out of order on incoming updates': + 'incoming-doc-version-out-of-order', + 'OpsOutOfOrderError: project structure version out of order': + 'chunk-project-version-out-of-order', + 'OpsOutOfOrderError: doc version out of order': + 'chunk-doc-version-out-of-order', + 'Error: failed to extend lock': 'lock-overrun', + 'Error: tried to release timed out lock': 'lock-overrun', + 'Error: Timeout': 'lock-overrun', + 'Error: sync ongoing': 'sync-ongoing', + 'SyncError: unexpected resyncProjectStructure update': 'sync-error', + '[object Error]': 'unknown-error-object', + 'UpdateWithUnknownFormatError: update with unknown format': + 'unknown-format', + 'Error: update with unknown format': 'unknown-format', + 'TextOperationError: The base length of the second operation has to be the target length of the first operation': + 'text-op-error', + 'Error: ENOSPC: no space left on device, write': 'ENOSPC', + '*': 'other', + } + + // set all the known errors to zero if not present (otherwise gauges stay on their last value) + const summaryCounts = {} + const summaryAttempts = {} + const summaryRequests = {} + const summaryMaxQueueSize = {} + + for (failureType in shortNames) { + label = shortNames[failureType] + summaryCounts[label] = 0 + summaryAttempts[label] = 0 + summaryRequests[label] = 0 + summaryMaxQueueSize[label] = 0 + } + + // record a metric for each type of failure + for (failureType in failureCounts) { + const failureCount = failureCounts[failureType] + label = shortNames[failureType] || shortNames['*'] + summaryCounts[label] += failureCount + summaryAttempts[label] += failureAttempts[failureType] + summaryRequests[label] += failureRequests[failureType] + summaryMaxQueueSize[label] = Math.max( + maxQueueSize[failureType], + summaryMaxQueueSize[label] + ) + } + + for (label in summaryCounts) { + const count = summaryCounts[label] + metrics.globalGauge('failed', count, 1, { status: label }) + } + + for (label in summaryAttempts) { + attempts = summaryAttempts[label] + metrics.globalGauge('attempts', attempts, 1, { status: label }) + } + + for (label in summaryRequests) { + requests = summaryRequests[label] + metrics.globalGauge('requests', requests, 1, { status: label }) + } + + for (label in summaryMaxQueueSize) { + const queueSize = summaryMaxQueueSize[label] + metrics.globalGauge('max-queue-size', queueSize, 1, { status: label }) + } + + return callback(null, { + counts: summaryCounts, + attempts: summaryAttempts, + requests: summaryRequests, + maxQueueSize: summaryMaxQueueSize, + }) + }) +} + +export const promises = { + getFailedProjects: promisify(getFailedProjects), + record: promisify(record), + getFailureRecord: promisify(getFailureRecord), +} diff --git a/services/project-history/app/js/Errors.js b/services/project-history/app/js/Errors.js new file mode 100644 index 0000000000..54aab952a9 --- /dev/null +++ b/services/project-history/app/js/Errors.js @@ -0,0 +1,10 @@ +import OError from '@overleaf/o-error' + +export class NotFoundError extends OError {} +export class BadRequestError extends OError {} +export class SyncError extends OError {} +export class OpsOutOfOrderError extends OError {} +export class InconsistentChunkError extends OError {} +export class UpdateWithUnknownFormatError extends OError {} +export class UnexpectedOpTypeError extends OError {} +export class TooManyRequestsError extends OError {} diff --git a/services/project-history/app/js/FileTreeDiffGenerator.js b/services/project-history/app/js/FileTreeDiffGenerator.js new file mode 100644 index 0000000000..49a87d3821 --- /dev/null +++ b/services/project-history/app/js/FileTreeDiffGenerator.js @@ -0,0 +1,143 @@ +/* eslint-disable + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import Core from 'overleaf-editor-core' +import logger from '@overleaf/logger' +import * as Errors from './Errors.js' + +const { MoveFileOperation, AddFileOperation, EditFileOperation } = Core + +export function buildDiff(chunk, fromVersion, toVersion) { + chunk = Core.Chunk.fromRaw(chunk.chunk) + const chunkStartVersion = chunk.getStartVersion() + + const diff = _getInitialDiffSnapshot(chunk, fromVersion) + + const changes = chunk + .getChanges() + .slice(fromVersion - chunkStartVersion, toVersion - chunkStartVersion) + for (let i = 0; i < changes.length; i++) { + const change = changes[i] + for (const operation of Array.from(change.getOperations())) { + if (operation.pathname === null || operation.pathname === '') { + // skip operations for missing files + logger.warn({ diff, operation }, 'invalid pathname in operation') + } else if (operation instanceof EditFileOperation) { + _applyEditFileToDiff(diff, operation) + } else if (operation instanceof AddFileOperation) { + _applyAddFileToDiff(diff, operation) + } else if (operation instanceof MoveFileOperation) { + if (operation.isRemoveFile()) { + const deletedAtV = fromVersion + i + _applyDeleteFileToDiff(diff, operation, deletedAtV) + } else { + _applyMoveFileToDiff(diff, operation) + } + } + } + } + + return Object.values(diff) +} + +function _getInitialDiffSnapshot(chunk, fromVersion) { + // Start with a 'diff' which is snapshot of the filetree at the beginning, + // with nothing in the diff marked as changed. + // Use a bare object to protect against reserved names. + const diff = Object.create(null) + const pathnames = _getInitialPathnames(chunk, fromVersion) + for (const pathname of Array.from(pathnames)) { + diff[pathname] = { pathname } + } + return diff +} + +function _getInitialPathnames(chunk, fromVersion) { + const snapshot = chunk.getSnapshot() + const changes = chunk + .getChanges() + .slice(0, fromVersion - chunk.getStartVersion()) + snapshot.applyAll(changes) + const pathnames = snapshot.getFilePathnames() + return pathnames +} + +function _applyAddFileToDiff(diff, operation) { + const change = diff[operation.pathname] + if (change != null) { + // already exists, likely a delete so just cancel that and put the file back to unchanged + if (change.operation !== 'removed') { + const err = new Errors.InconsistentChunkError( + 'trying to add file that already exists', + { diff, operation } + ) + throw err + } + delete diff[operation.pathname].operation + return delete diff[operation.pathname].deletedAtV + } else { + return (diff[operation.pathname] = { + pathname: operation.pathname, + operation: 'added', + }) + } +} + +function _applyEditFileToDiff(diff, operation) { + const change = diff[operation.pathname] + if ((change != null ? change.operation : undefined) == null) { + // avoid exception for non-existent change + return (diff[operation.pathname] = { + pathname: operation.pathname, + operation: 'edited', + }) + } +} + +function _applyMoveFileToDiff(diff, operation) { + if ( + diff[operation.newPathname] != null && + diff[operation.newPathname].operation !== 'removed' + ) { + const err = new Errors.InconsistentChunkError( + 'trying to move to file that already exists', + { diff, operation } + ) + throw err + } + const change = diff[operation.pathname] + if (change == null) { + logger.warn({ diff, operation }, 'tried to rename non-existent file') + return + } + change.newPathname = operation.newPathname + if (change.operation === 'added') { + // If this file was added this time, just leave it as an add, but + // at the new name. + change.pathname = operation.newPathname + delete change.newPathname + } else { + change.operation = 'renamed' + } + diff[operation.newPathname] = change + return delete diff[operation.pathname] +} + +function _applyDeleteFileToDiff(diff, operation, deletedAtV) { + // avoid exception for non-existent change + if (diff[operation.pathname] != null) { + diff[operation.pathname].operation = 'removed' + } + return diff[operation.pathname] != null + ? (diff[operation.pathname].deletedAtV = deletedAtV) + : undefined +} diff --git a/services/project-history/app/js/FlushManager.js b/services/project-history/app/js/FlushManager.js new file mode 100644 index 0000000000..526aa57876 --- /dev/null +++ b/services/project-history/app/js/FlushManager.js @@ -0,0 +1,159 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import async from 'async' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import _ from 'lodash' +import * as RedisManager from './RedisManager.js' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as ErrorRecorder from './ErrorRecorder.js' + +export function flushIfOld(project_id, cutoffTime, callback) { + if (callback == null) { + callback = function () {} + } + return RedisManager.getFirstOpTimestamp( + project_id, + function (err, firstOpTimestamp) { + if (err != null) { + return callback(OError.tag(err)) + } + // in the normal case, the flush marker will be set with the + // timestamp of the oldest operation in the queue by docupdater + if (firstOpTimestamp != null) { + if (firstOpTimestamp < cutoffTime) { + logger.debug( + { project_id, firstOpTimestamp, cutoffTime }, + 'flushing old project' + ) + return UpdatesProcessor.processUpdatesForProject( + project_id, + ( + err // always clear the flush marker after processing the project + ) => + RedisManager.clearFirstOpTimestamp(project_id, function (e) { + if (e != null) { + logger.error( + { project_id, flushErr: e }, + 'failed to clear flush marker' + ) + } + if (err) { + OError.tag(err) + } + return callback(err) + }) + ) // return the original error from processUpdatesFromProject + } else { + return callback() + } + } else { + return RedisManager.setFirstOpTimestamp(project_id, callback) + } + } + ) +} + +export function flushOldOps(options, callback) { + if (callback == null) { + callback = function () {} + } + logger.debug({ options }, 'starting flush of old ops') + // allow running flush in background for cron jobs + if (options.background) { + // return immediate response to client, then discard callback + callback(null, { message: 'running flush in background' }) + callback = function () {} + } + return RedisManager.getProjectIdsWithHistoryOps( + null, + function (error, projectIds) { + if (error != null) { + return callback(OError.tag(error)) + } + return ErrorRecorder.getFailedProjects(function ( + error, + projectHistoryFailures + ) { + if (error != null) { + return callback(OError.tag(error)) + } + // exclude failed projects already in projectHistoryFailures + const failedProjects = new Set() + for (const entry of Array.from(projectHistoryFailures)) { + failedProjects.add(entry.project_id) + } + // randomise order so we get different projects if there is a limit + projectIds = _.shuffle(projectIds) + const maxAge = options.maxAge || 6 * 3600 // default to 6 hours + const cutoffTime = new Date(Date.now() - maxAge * 1000) + const startTime = new Date() + let count = 0 + const jobs = projectIds.map( + project_id => + function (cb) { + const timeTaken = new Date() - startTime + count++ + if ( + (options != null ? options.timeout : undefined) && + timeTaken > options.timeout + ) { + // finish early due to timeout, return an error to bail out of the async iteration + logger.debug('background retries timed out') + return cb(new OError('retries timed out')) + } + if ( + (options != null ? options.limit : undefined) && + count > options.limit + ) { + // finish early due to reaching limit, return an error to bail out of the async iteration + logger.debug({ count }, 'background retries hit limit') + return cb(new OError('hit limit')) + } + if (failedProjects.has(project_id)) { + // skip failed projects + return setTimeout(cb, options.queueDelay || 100) // pause between flushes + } + return flushIfOld(project_id, cutoffTime, function (err) { + if (err != null) { + logger.warn( + { project_id, flushErr: err }, + 'error flushing old project' + ) + } + return setTimeout(cb, options.queueDelay || 100) + }) + } + ) // pause between flushes + return async.series(async.reflectAll(jobs), function (error, results) { + const success = [] + const failure = [] + results.forEach((result, i) => { + if ( + result.error != null && + !['retries timed out', 'hit limit'].includes( + result?.error?.message + ) + ) { + // ignore expected errors + return failure.push(projectIds[i]) + } else { + return success.push(projectIds[i]) + } + }) + return callback(error, { success, failure, failedProjects }) + }) + }) + } + ) +} diff --git a/services/project-history/app/js/HashManager.js b/services/project-history/app/js/HashManager.js new file mode 100644 index 0000000000..46a476f899 --- /dev/null +++ b/services/project-history/app/js/HashManager.js @@ -0,0 +1,63 @@ +/* eslint-disable + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import fs from 'fs' +import crypto from 'crypto' +import _ from 'lodash' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' + +export function _getBlobHashFromString(string) { + const byteLength = Buffer.byteLength(string) + const hash = crypto.createHash('sha1') + hash.setEncoding('hex') + hash.update('blob ' + byteLength + '\x00') + hash.update(string, 'utf8') + hash.end() + return hash.read() +} + +export function _getBlobHash(fsPath, _callback) { + if (_callback == null) { + _callback = function () {} + } + const callback = _.once(_callback) + + return fs.stat(fsPath, function (err, stats) { + if (err != null) { + OError.tag(err, 'failed to stat file in _getBlobHash', { fsPath }) + return callback(err) + } + const byteLength = stats.size + const hash = crypto.createHash('sha1') + hash.setEncoding('hex') + hash.update('blob ' + byteLength + '\x00') + + const stream = fs.createReadStream(fsPath) + + stream.on('error', function (err) { + return callback( + OError.tag(err, 'error streaming file from disk', { + fsPath, + byteLength, + }) + ) + }) + + stream.on('end', function () { + hash.end() + return callback(null, hash.read(), byteLength) + }) + + return stream.pipe(hash) + }) +} diff --git a/services/project-history/app/js/HealthChecker.js b/services/project-history/app/js/HealthChecker.js new file mode 100644 index 0000000000..009657269a --- /dev/null +++ b/services/project-history/app/js/HealthChecker.js @@ -0,0 +1,82 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { ObjectId } from 'mongodb' +import request from 'request' +import async from 'async' +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import * as LockManager from './LockManager.js' + +const { port } = settings.internal.history + +export function check(callback) { + const project_id = ObjectId(settings.history.healthCheck.project_id) + const url = `http://localhost:${port}/project/${project_id}` + logger.debug({ project_id }, 'running health check') + const jobs = [ + cb => + request.get( + { url: `http://localhost:${port}/check_lock`, timeout: 3000 }, + function (err, res, body) { + if (err != null) { + OError.tag(err, 'error checking lock for health check', { + project_id, + }) + logger.err(err) + return cb(err) + } else if ((res != null ? res.statusCode : undefined) !== 200) { + return cb(new Error(`status code not 200, it's ${res.statusCode}`)) + } else { + return cb() + } + } + ), + cb => + request.post( + { url: `${url}/flush`, timeout: 10000 }, + function (err, res, body) { + if (err != null) { + OError.tag(err, 'error flushing for health check', { project_id }) + logger.err(err) + return cb(err) + } else if ((res != null ? res.statusCode : undefined) !== 204) { + return cb(new Error(`status code not 204, it's ${res.statusCode}`)) + } else { + return cb() + } + } + ), + cb => + request.get( + { url: `${url}/updates`, timeout: 10000 }, + function (err, res, body) { + if (err != null) { + OError.tag(err, 'error getting updates for health check', { + project_id, + }) + logger.err(err) + return cb(err) + } else if ((res != null ? res.statusCode : undefined) !== 200) { + return cb(new Error(`status code not 200, it's ${res.statusCode}`)) + } else { + return cb() + } + } + ), + ] + return async.series(jobs, callback) +} + +export function checkLock(callback) { + return LockManager.healthCheck(callback) +} diff --git a/services/project-history/app/js/HistoryApiManager.js b/services/project-history/app/js/HistoryApiManager.js new file mode 100644 index 0000000000..849fced2c7 --- /dev/null +++ b/services/project-history/app/js/HistoryApiManager.js @@ -0,0 +1,23 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import * as WebApiManager from './WebApiManager.js' +import logger from '@overleaf/logger' + +export function shouldUseProjectHistory(project_id, callback) { + if (callback == null) { + callback = function () {} + } + return WebApiManager.getHistoryId(project_id, (error, historyId) => + callback(error, historyId != null) + ) +} diff --git a/services/project-history/app/js/HistoryStoreManager.js b/services/project-history/app/js/HistoryStoreManager.js new file mode 100644 index 0000000000..6a40284e12 --- /dev/null +++ b/services/project-history/app/js/HistoryStoreManager.js @@ -0,0 +1,447 @@ +import fs from 'fs' +import request from 'request' +import stream from 'stream' +import logger from '@overleaf/logger' +import _ from 'lodash' +import BPromise from 'bluebird' +import { URL } from 'url' +import OError from '@overleaf/o-error' +import Settings from '@overleaf/settings' +import * as Versions from './Versions.js' +import * as Errors from './Errors.js' +import * as LocalFileWriter from './LocalFileWriter.js' +import * as HashManager from './HashManager.js' + +const HTTP_REQUEST_TIMEOUT = 300 * 1000 // 5 minutes + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +class StringStream extends stream.Readable { + _read() {} +} + +_mocks.getMostRecentChunk = (projectId, historyId, callback) => { + const path = `projects/${historyId}/latest/history` + logger.debug({ projectId, historyId }, 'getting chunk from history service') + _requestChunk({ path, json: true }, callback) +} + +export function getMostRecentChunk(...args) { + _mocks.getMostRecentChunk(...args) +} + +export function getChunkAtVersion(projectId, historyId, version, callback) { + const path = `projects/${historyId}/versions/${version}/history` + logger.debug( + { projectId, historyId, version }, + 'getting chunk from history service for version' + ) + _requestChunk({ path, json: true }, callback) +} + +export function getMostRecentVersion(projectId, historyId, callback) { + getMostRecentChunk(projectId, historyId, (error, chunk) => { + if (error) { + return callback(OError.tag(error)) + } + const mostRecentVersion = + chunk.chunk.startVersion + (chunk.chunk.history.changes || []).length + const lastChange = _.last( + _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp) + ) + // find the latest project and doc versions in the chunk + _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => + _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => { + // return the project and doc versions + const projectStructureAndDocVersions = { + project: projectVersion, + docs: v2DocVersions, + } + callback( + err1 || err2, + mostRecentVersion, + projectStructureAndDocVersions, + lastChange + ) + }) + ) + }) +} + +function _requestChunk(options, callback) { + _requestHistoryService(options, (err, chunk) => { + if (err) { + return callback(OError.tag(err)) + } + if ( + chunk == null || + chunk.chunk == null || + chunk.chunk.startVersion == null + ) { + return callback(new OError('unexpected response')) + } + callback(null, chunk) + }) +} + +function _getLatestProjectVersion(projectId, chunk, callback) { + // find the initial project version + let projectVersion = + chunk.chunk.history.snapshot && chunk.chunk.history.snapshot.projectVersion + // keep track of any errors + let error = null + // iterate over the changes in chunk to find the most recent project version + for (const change of chunk.chunk.history.changes || []) { + if (change.projectVersion != null) { + if ( + projectVersion != null && + Versions.lt(change.projectVersion, projectVersion) + ) { + logger.warn( + { projectId, chunk, projectVersion, change }, + 'project structure version out of order in chunk' + ) + if (!error) { + error = new Errors.OpsOutOfOrderError( + 'project structure version out of order' + ) + } + } else { + projectVersion = change.projectVersion + } + } + } + callback(error, projectVersion) +} + +function _getLatestV2DocVersions(projectId, chunk, callback) { + // find the initial doc versions (indexed by docId as this is immutable) + const v2DocVersions = + (chunk.chunk.history.snapshot && + chunk.chunk.history.snapshot.v2DocVersions) || + {} + // keep track of any errors + let error = null + // iterate over the changes in the chunk to find the most recent doc versions + for (const change of chunk.chunk.history.changes || []) { + if (change.v2DocVersions != null) { + for (const docId in change.v2DocVersions) { + const docInfo = change.v2DocVersions[docId] + const { v } = docInfo + if ( + v2DocVersions[docId] && + v2DocVersions[docId].v != null && + Versions.lt(v, v2DocVersions[docId].v) + ) { + logger.warn( + { + projectId, + docId, + changeVersion: docInfo, + previousVersion: v2DocVersions[docId], + }, + 'doc version out of order in chunk' + ) + if (!error) { + error = new Errors.OpsOutOfOrderError('doc version out of order') + } + } else { + v2DocVersions[docId] = docInfo + } + } + } + } + callback(error, v2DocVersions) +} + +export function getProjectBlob(historyId, blobHash, callback) { + logger.debug({ historyId, blobHash }, 'getting blob from history service') + _requestHistoryService( + { path: `projects/${historyId}/blobs/${blobHash}` }, + callback + ) +} + +export function getProjectBlobStream(historyId, blobHash, callback) { + logger.debug( + { historyId, blobHash }, + 'getting blob stream from history service' + ) + _requestHistoryServiceStream( + { path: `projects/${historyId}/blobs/${blobHash}` }, + callback + ) +} + +export function sendChanges( + projectId, + historyId, + changes, + endVersion, + callback +) { + logger.debug( + { projectId, historyId, endVersion }, + 'sending changes to history service' + ) + _requestHistoryService( + { + path: `projects/${historyId}/legacy_changes`, + qs: { end_version: endVersion }, + method: 'POST', + json: changes, + }, + error => { + if (error) { + OError.tag(error, 'failed to send changes to v1', { + projectId, + historyId, + endVersion, + errorCode: error.code, + statusCode: error.statusCode, + body: error.body, + }) + logger.warn(error) + return callback(error) + } + callback() + } + ) +} + +export function createBlobForUpdate(projectId, historyId, update, callback) { + callback = _.once(callback) + + if (update.doc != null && update.docLines != null) { + const stringStream = new StringStream() + stringStream.push(update.docLines) + stringStream.push(null) + + LocalFileWriter.bufferOnDisk( + stringStream, + `project-${projectId}-doc-${update.doc}`, + (fsPath, cb) => { + _createBlob(historyId, fsPath, cb) + }, + callback + ) + } else if (update.file != null && update.url != null) { + // Rewrite the filestore url to point to the location in the local + // settings for this service (this avoids problems with cross- + // datacentre requests when running filestore in multiple locations). + const { pathname: fileStorePath } = new URL(update.url) + const urlMatch = /^\/project\/([0-9a-f]{24})\/file\/([0-9a-f]{24})$/.exec( + fileStorePath + ) + if (urlMatch == null) { + return callback(new OError('invalid file for blob creation')) + } + if (urlMatch[1] !== projectId) { + return callback(new OError('invalid project for blob creation')) + } + const fileId = urlMatch[2] + const fileStoreStream = request.get({ + url: `${Settings.apis.filestore.url}/project/${projectId}/file/${fileId}`, + timeout: HTTP_REQUEST_TIMEOUT, + }) + fileStoreStream.pause() + fileStoreStream.on('error', err => { + callback(OError.tag(err, 'error from filestore', { url: update.url })) + }) + fileStoreStream.on('response', response => { + if (response.statusCode >= 200 && response.statusCode < 300) { + LocalFileWriter.bufferOnDisk( + fileStoreStream, + `project-${projectId}-file-${fileId}`, + (fsPath, cb) => { + _createBlob(historyId, fsPath, cb) + }, + callback + ) + fileStoreStream.resume() // start data flowing when ready + } else if (response.statusCode === 404) { + logger.warn( + { projectId, historyId, fileStoreUrl: update.url }, + 'File contents not found in filestore. Storing in history as an empty file' + ) + const emptyStream = new StringStream() + LocalFileWriter.bufferOnDisk( + emptyStream, + `project-${projectId}-file-${fileId}`, + (fsPath, cb) => { + _createBlob(historyId, fsPath, cb) + }, + callback + ) + fileStoreStream.resume() // Drain the filestore stream + emptyStream.push(null) // send an EOF signal + } else { + const error = new OError( + `bad response from filestore: ${response.statusCode}`, + { url: update.url, statusCode: response.statusCode } + ) + fileStoreStream.resume() // See https://github.com/overleaf/write_latex/wiki/Streams-and-pipes-in-Node.js#discard-data-if-necessary-in-the-response-handler + callback(error) + } + }) + } else { + const error = new OError('invalid update for blob creation') + callback(error) + } +} + +function _createBlob(historyId, fsPath, _callback) { + const callback = _.once(_callback) + + HashManager._getBlobHash(fsPath, (error, hash, byteLength) => { + if (error) { + return callback(OError.tag(error)) + } + const outStream = fs.createReadStream(fsPath) + + outStream.on('error', err => { + callback( + OError.tag(err, 'error streaming file from disk', { + fsPath, + hash, + byteLength, + }) + ) + }) + + logger.debug( + { fsPath, hash, byteLength }, + 'sending blob to history service' + ) + _requestHistoryService( + { + method: 'PUT', + path: `projects/${historyId}/blobs/${hash}`, + body: outStream, + }, + error => { + if (error) { + return callback(OError.tag(error)) + } + callback(null, hash) + } + ) + }) +} + +export function initializeProject(historyId, callback) { + _requestHistoryService( + { + method: 'POST', + path: 'projects', + json: historyId == null ? true : { projectId: historyId }, + }, + (error, project) => { + if (error) { + return callback(OError.tag(error)) + } + + const id = project.projectId + if (id == null) { + error = new OError('history store did not return a project id', id) + return callback(error) + } + + callback(null, id) + } + ) +} + +export function deleteProject(projectId, callback) { + _requestHistoryService( + { method: 'DELETE', path: `projects/${projectId}` }, + callback + ) +} + +const getProjectBlobAsync = BPromise.promisify(getProjectBlob) + +class BlobStore { + constructor(projectId) { + this.projectId = projectId + } + + getString(hash) { + return getProjectBlobAsync(this.projectId, hash) + } +} + +export function getBlobStore(projectId) { + return new BlobStore(projectId) +} + +function _requestOptions(options) { + const requestOptions = { + method: options.method || 'GET', + url: `${Settings.overleaf.history.host}/${options.path}`, + timeout: HTTP_REQUEST_TIMEOUT, + auth: { + user: Settings.overleaf.history.user, + pass: Settings.overleaf.history.pass, + sendImmediately: true, + }, + } + + if (options.json != null) { + requestOptions.json = options.json + } + + if (options.body != null) { + requestOptions.body = options.body + } + + if (options.qs != null) { + requestOptions.qs = options.qs + } + + return requestOptions +} + +function _requestHistoryService(options, callback) { + const requestOptions = _requestOptions(options) + request(requestOptions, (error, res, body) => { + if (error) { + return callback(OError.tag(error)) + } + + if (res.statusCode >= 200 && res.statusCode < 300) { + callback(null, body) + } else { + error = new OError( + `history store a non-success status code: ${res.statusCode}` + ) + error.statusCode = res.statusCode + error.body = body + logger.warn({ err: error }, error.message) + callback(error) + } + }) +} + +function _requestHistoryServiceStream(options, callback) { + callback = _.once(callback) + const requestOptions = _requestOptions(options) + const stream = request(requestOptions) + stream.on('error', callback) + stream.on('response', res => { + if (res.statusCode >= 200 && res.statusCode < 300) { + callback(null, stream) + } else { + const error = new OError( + `history store a non-success status code: ${res.statusCode}` + ) + logger.warn({ err: error, options }, error.message) + callback(error) + } + }) +} diff --git a/services/project-history/app/js/HttpController.js b/services/project-history/app/js/HttpController.js new file mode 100644 index 0000000000..bb08a7a4ef --- /dev/null +++ b/services/project-history/app/js/HttpController.js @@ -0,0 +1,464 @@ +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import request from 'request' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as SummarizedUpdatesManager from './SummarizedUpdatesManager.js' +import * as DiffManager from './DiffManager.js' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as WebApiManager from './WebApiManager.js' +import * as SnapshotManager from './SnapshotManager.js' +import * as HealthChecker from './HealthChecker.js' +import * as SyncManager from './SyncManager.js' +import * as ErrorRecorder from './ErrorRecorder.js' +import * as RedisManager from './RedisManager.js' +import * as LabelsManager from './LabelsManager.js' +import * as HistoryApiManager from './HistoryApiManager.js' +import * as RetryManager from './RetryManager.js' +import * as FlushManager from './FlushManager.js' + +export function getProjectBlob(req, res, next) { + const projectId = req.params.project_id + const blobHash = req.params.hash + HistoryStoreManager.getProjectBlobStream( + projectId, + blobHash, + (err, stream) => { + if (err != null) { + return next(OError.tag(err)) + } + stream.pipe(res) + } + ) +} + +export function initializeProject(req, res, next) { + const { historyId } = req.body + HistoryStoreManager.initializeProject(historyId, (error, id) => { + if (error != null) { + return next(OError.tag(error)) + } + res.json({ project: { id } }) + }) +} + +export function flushProject(req, res, next) { + const projectId = req.params.project_id + if (req.query.debug) { + logger.debug( + { projectId }, + 'compressing project history in single-step mode' + ) + UpdatesProcessor.processSingleUpdateForProject(projectId, error => { + if (error != null) { + return next(OError.tag(error)) + } + res.sendStatus(204) + }) + } else if (req.query.bisect) { + logger.debug({ projectId }, 'compressing project history in bisect mode') + UpdatesProcessor.processUpdatesForProjectUsingBisect( + projectId, + UpdatesProcessor.REDIS_READ_BATCH_SIZE, + error => { + if (error != null) { + return next(OError.tag(error)) + } + res.sendStatus(204) + } + ) + } else { + logger.debug({ projectId }, 'compressing project history') + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error != null) { + return next(OError.tag(error)) + } + res.sendStatus(204) + }) + } +} + +export function dumpProject(req, res, next) { + const projectId = req.params.project_id + const batchSize = req.query.count || UpdatesProcessor.REDIS_READ_BATCH_SIZE + logger.debug({ projectId }, 'retrieving raw updates') + UpdatesProcessor.getRawUpdates(projectId, batchSize, (error, rawUpdates) => { + if (error != null) { + return next(OError.tag(error)) + } + res.json(rawUpdates) + }) +} + +export function flushOld(req, res, next) { + const { maxAge, queueDelay, limit, timeout, background } = req.query + const options = { maxAge, queueDelay, limit, timeout, background } + FlushManager.flushOldOps(options, (error, results) => { + if (error != null) { + return next(OError.tag(error)) + } + res.send(results) + }) +} + +export function getDiff(req, res, next) { + const projectId = req.params.project_id + const { pathname, from, to } = req.query + if (pathname == null) { + return res.sendStatus(400) + } + + logger.debug({ projectId, pathname, from, to }, 'getting diff') + DiffManager.getDiff(projectId, pathname, from, to, (error, diff) => { + if (error != null) { + return next(OError.tag(error)) + } + res.json({ diff }) + }) +} + +export function getFileTreeDiff(req, res, next) { + const projectId = req.params.project_id + const { to, from } = req.query + + DiffManager.getFileTreeDiff(projectId, from, to, (error, diff) => { + if (error != null) { + return next(OError.tag(error)) + } + res.json({ diff }) + }) +} + +export function getUpdates(req, res, next) { + const projectId = req.params.project_id + const { before, min_count: minCount } = req.query + SummarizedUpdatesManager.getSummarizedProjectUpdates( + projectId, + { before, min_count: minCount }, + (error, updates, nextBeforeTimestamp) => { + if (error != null) { + return next(OError.tag(error)) + } + for (const update of updates) { + // Sets don't JSONify, so convert to arrays + update.pathnames = Array.from(update.pathnames || []).sort() + } + res.json({ + updates, + nextBeforeTimestamp, + }) + } + ) +} + +export function latestVersion(req, res, next) { + const projectId = req.params.project_id + logger.debug({ projectId }, 'compressing project history and getting version') + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error != null) { + return next(OError.tag(error)) + } + WebApiManager.getHistoryId(projectId, (error, historyId) => { + if (error != null) { + return next(OError.tag(error)) + } + HistoryStoreManager.getMostRecentVersion( + projectId, + historyId, + (error, version, projectStructureAndDocVersions, lastChange) => { + if (error != null) { + return next(OError.tag(error)) + } + res.json({ + version, + timestamp: lastChange != null ? lastChange.timestamp : undefined, + v2Authors: lastChange != null ? lastChange.v2Authors : undefined, + }) + } + ) + }) + }) +} + +export function getFileSnapshot(req, res, next) { + const { project_id: projectId, version, pathname } = req.params + SnapshotManager.getFileSnapshotStream( + projectId, + version, + pathname, + (error, stream) => { + if (error != null) { + return next(OError.tag(error)) + } + stream.pipe(res) + } + ) +} + +export function getProjectSnapshot(req, res, next) { + const { project_id: projectId, version } = req.params + SnapshotManager.getProjectSnapshot( + projectId, + version, + (error, snapshotData) => { + if (error != null) { + return next(error) + } + res.json(snapshotData) + } + ) +} + +export function healthCheck(req, res) { + HealthChecker.check(err => { + if (err != null) { + logger.err({ err }, 'error performing health check') + res.sendStatus(500) + } else { + res.sendStatus(200) + } + }) +} + +export function checkLock(req, res) { + HealthChecker.checkLock(err => { + if (err != null) { + logger.err({ err }, 'error performing lock check') + res.sendStatus(500) + } else { + res.sendStatus(200) + } + }) +} + +export function resyncProject(req, res, next) { + const projectId = req.params.project_id + const options = {} + if (req.body.origin) { + options.origin = req.body.origin + } + if (req.query.force || req.body.force) { + // this will delete the queue and clear the sync state + // use if the project is completely broken + SyncManager.startHardResync(projectId, options, error => { + if (error != null) { + return next(error) + } + // flush the sync operations + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error != null) { + return next(error) + } + res.sendStatus(204) + }) + }) + } else { + SyncManager.startResync(projectId, options, error => { + if (error != null) { + return next(error) + } + // flush the sync operations + UpdatesProcessor.processUpdatesForProject(projectId, error => { + if (error != null) { + return next(error) + } + res.sendStatus(204) + }) + }) + } +} + +export function forceDebugProject(req, res, next) { + const projectId = req.params.project_id + // set the debug flag to true unless we see ?clear=true + const state = !req.query.clear + ErrorRecorder.setForceDebug(projectId, state, error => { + if (error != null) { + return next(error) + } + // display the failure record to help debugging + ErrorRecorder.getFailureRecord(projectId, (error, result) => { + if (error != null) { + return next(error) + } + res.send(result) + }) + }) +} + +export function getFailures(req, res, next) { + ErrorRecorder.getFailures((error, result) => { + if (error != null) { + return next(error) + } + res.send({ failures: result }) + }) +} + +export function getQueueCounts(req, res, next) { + RedisManager.getProjectIdsWithHistoryOpsCount((err, queuedProjectsCount) => { + if (err != null) { + return next(err) + } + res.send({ queuedProjects: queuedProjectsCount }) + }) +} + +export function getLabels(req, res, next) { + const projectId = req.params.project_id + HistoryApiManager.shouldUseProjectHistory( + projectId, + (error, shouldUseProjectHistory) => { + if (error != null) { + return next(error) + } + if (shouldUseProjectHistory) { + LabelsManager.getLabels(projectId, (error, labels) => { + if (error != null) { + return next(error) + } + res.json(labels) + }) + } else { + res.sendStatus(409) + } + } + ) +} + +export function createLabel(req, res, next) { + const { project_id: projectId, user_id: userId } = req.params + const { + version, + comment, + created_at: createdAt, + validate_exists: validateExists, + } = req.body + HistoryApiManager.shouldUseProjectHistory( + projectId, + (error, shouldUseProjectHistory) => { + if (error != null) { + return next(error) + } + if (shouldUseProjectHistory) { + LabelsManager.createLabel( + projectId, + userId, + version, + comment, + createdAt, + validateExists, + (error, label) => { + if (error != null) { + return next(error) + } + res.json(label) + } + ) + } else { + logger.error( + { + projectId, + userId, + version, + comment, + createdAt, + validateExists, + }, + 'not using v2 history' + ) + res.sendStatus(409) + } + } + ) +} + +export function deleteLabel(req, res, next) { + const { + project_id: projectId, + user_id: userId, + label_id: labelId, + } = req.params + LabelsManager.deleteLabel(projectId, userId, labelId, error => { + if (error != null) { + return next(error) + } + res.sendStatus(204) + }) +} + +export function retryFailures(req, res, next) { + const { failureType, timeout, limit, callbackUrl } = req.query + if (callbackUrl) { + // send response but run in background when callbackUrl provided + res.send({ retryStatus: 'running retryFailures in background' }) + } + RetryManager.retryFailures( + { failureType, timeout, limit }, + (error, result) => { + if (callbackUrl) { + // if present, notify the callbackUrl on success + if (!error) { + // Needs Node 12 + // const callbackHeaders = Object.fromEntries(Object.entries(req.headers || {}).filter(([k,v]) => k.match(/^X-CALLBACK-/i))) + const callbackHeaders = {} + for (const key of Object.getOwnPropertyNames( + req.headers || {} + ).filter(key => key.match(/^X-CALLBACK-/i))) { + const found = key.match(/^X-CALLBACK-(.*)/i) + callbackHeaders[found[1]] = req.headers[key] + } + request({ url: callbackUrl, headers: callbackHeaders }) + } + } else { + if (error != null) { + return next(error) + } + res.send({ retryStatus: result }) + } + } + ) +} + +export function transferLabels(req, res, next) { + const { from_user: fromUser, to_user: toUser } = req.params + LabelsManager.transferLabels(fromUser, toUser, error => { + if (error != null) { + return next(error) + } + res.sendStatus(204) + }) +} + +export function deleteProject(req, res, next) { + const { project_id: projectId } = req.params + // clear the timestamp before clearing the queue, + // because the queue location is used in the migration + RedisManager.clearFirstOpTimestamp(projectId, err => { + if (err) { + return next(err) + } + RedisManager.clearCachedHistoryId(projectId, err => { + if (err) { + return next(err) + } + RedisManager.destroyDocUpdatesQueue(projectId, err => { + if (err) { + return next(err) + } + SyncManager.clearResyncState(projectId, err => { + if (err) { + return next(err) + } + // The third parameter to the following call is the error. Calling it + // with null will remove any failure record for this project. + ErrorRecorder.record(projectId, 0, null, err => { + if (err) { + return next(err) + } + res.sendStatus(204) + }) + }) + }) + }) + }) +} diff --git a/services/project-history/app/js/LabelsManager.js b/services/project-history/app/js/LabelsManager.js new file mode 100644 index 0000000000..db70ddd021 --- /dev/null +++ b/services/project-history/app/js/LabelsManager.js @@ -0,0 +1,170 @@ +// TODO: This file was created by bulk-decaffeinate. +// Sanity-check the conversion and remove this comment. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS201: Simplify complex destructure assignments + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import OError from '@overleaf/o-error' +import { db, ObjectId } from './mongodb.js' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as WebApiManager from './WebApiManager.js' + +export function getLabels(projectId, callback) { + return _toObjectId(projectId, function (error, projectId) { + if (error != null) { + return callback(OError.tag(error)) + } + return db.projectHistoryLabels + .find({ project_id: ObjectId(projectId) }) + .toArray(function (error, labels) { + if (error != null) { + return callback(OError.tag(error)) + } + const formattedLabels = labels.map(_formatLabel) + return callback(null, formattedLabels) + }) + }) +} + +export function createLabel( + projectId, + userId, + version, + comment, + createdAt, + shouldValidateExists, + callback +) { + const validateVersionExists = function (callback) { + if (shouldValidateExists === false) { + return callback() + } else { + return _validateChunkExistsForVersion( + projectId.toString(), + version, + callback + ) + } + } + + return _toObjectId(projectId, userId, function (error, projectId, userId) { + if (error != null) { + return callback(OError.tag(error)) + } + return validateVersionExists(function (error) { + if (error != null) { + return callback(OError.tag(error)) + } + + createdAt = createdAt != null ? new Date(createdAt) : new Date() + + const label = { + project_id: ObjectId(projectId), + comment, + version, + user_id: ObjectId(userId), + created_at: createdAt, + } + db.projectHistoryLabels.insertOne(label, function (error, confirmation) { + if (error != null) { + return callback(OError.tag(error)) + } + label._id = confirmation.insertedId + callback(null, _formatLabel(label)) + }) + }) + }) +} + +export function deleteLabel(projectId, userId, labelId, callback) { + return _toObjectId( + projectId, + userId, + labelId, + function (error, projectId, userId, labelId) { + if (error != null) { + return callback(OError.tag(error)) + } + return db.projectHistoryLabels.deleteOne( + { + _id: ObjectId(labelId), + project_id: ObjectId(projectId), + user_id: ObjectId(userId), + }, + callback + ) + } + ) +} + +export function transferLabels(fromUserId, toUserId, callback) { + return _toObjectId( + fromUserId, + toUserId, + function (error, fromUserId, toUserId) { + if (error != null) { + return callback(OError.tag(error)) + } + return db.projectHistoryLabels.updateMany( + { + user_id: fromUserId, + }, + { + $set: { user_id: toUserId }, + }, + callback + ) + } + ) +} + +function _toObjectId(...args1) { + const adjustedLength = Math.max(args1.length, 1) + const args = args1.slice(0, adjustedLength - 1) + const callback = args1[adjustedLength - 1] + try { + const ids = args.map(ObjectId) + return callback(null, ...Array.from(ids)) + } catch (error) { + return callback(error) + } +} + +function _formatLabel(label) { + return { + id: label._id, + comment: label.comment, + version: label.version, + user_id: label.user_id, + created_at: label.created_at, + } +} + +function _validateChunkExistsForVersion(projectId, version, callback) { + return UpdatesProcessor.processUpdatesForProject(projectId, function (error) { + if (error != null) { + return callback(error) + } + return WebApiManager.getHistoryId(projectId, function (error, historyId) { + if (error != null) { + return callback(error) + } + return HistoryStoreManager.getChunkAtVersion( + projectId, + historyId, + version, + function (error) { + if (error != null) { + return callback(error) + } + return callback() + } + ) + }) + }) +} diff --git a/services/project-history/app/js/LargeFileManager.js b/services/project-history/app/js/LargeFileManager.js new file mode 100644 index 0000000000..239057c640 --- /dev/null +++ b/services/project-history/app/js/LargeFileManager.js @@ -0,0 +1,88 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import fs from 'fs' +import { v1 as uuid } from 'uuid' +import Path from 'path' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' +import _ from 'lodash' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as HashManager from './HashManager.js' + +export function createStub(fsPath, fileId, fileSize, fileHash, callback) { + if (callback == null) { + callback = function () {} + } + callback = _.once(callback) + const newFsPath = Path.join( + Settings.path.uploadFolder, + uuid() + `-${fileId}-stub` + ) + const writeStream = fs.createWriteStream(newFsPath) + writeStream.on('error', function (error) { + OError.tag(error, 'error writing stub file', { fsPath, newFsPath }) + return fs.unlink(newFsPath, () => callback(error)) + }) + writeStream.on('finish', function () { + logger.debug( + { fsPath, fileId, fileSize, fileHash }, + 'replaced large file with stub' + ) + return callback(null, newFsPath) + }) // let the consumer unlink the file + const stubLines = [ + 'FileTooLargeError v1', + 'File too large to be stored in history service', + `id ${fileId}`, + `size ${fileSize} bytes`, + `hash ${fileHash}`, + '\0', // null byte to make this a binary file + ] + writeStream.write(stubLines.join('\n')) + return writeStream.end() +} + +export function replaceWithStubIfNeeded(fsPath, fileId, fileSize, callback) { + if (callback == null) { + callback = function () {} + } + if ( + Settings.maxFileSizeInBytes != null && + fileSize > Settings.maxFileSizeInBytes + ) { + logger.error( + { fsPath, fileId, maxFileSizeInBytes: Settings.maxFileSizeInBytes }, + 'file too large, will use stub' + ) + return HashManager._getBlobHash(fsPath, function (error, fileHash) { + if (error != null) { + return callback(error) + } + return createStub( + fsPath, + fileId, + fileSize, + fileHash, + function (error, newFsPath) { + if (error != null) { + return callback(error) + } + return callback(null, newFsPath) + } + ) + }) + } else { + return callback(null, fsPath) + } +} diff --git a/services/project-history/app/js/LocalFileWriter.js b/services/project-history/app/js/LocalFileWriter.js new file mode 100644 index 0000000000..9b82ef278a --- /dev/null +++ b/services/project-history/app/js/LocalFileWriter.js @@ -0,0 +1,127 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import fs from 'fs' +import { v1 as uuid } from 'uuid' +import path from 'path' +import Url from 'url' +import _ from 'lodash' +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' +import OError from '@overleaf/o-error' +import * as LargeFileManager from './LargeFileManager.js' + +// +// This method takes a stream and provides you a new stream which is now +// reading from disk. +// +// This is useful if we're piping one network stream to another. If the stream +// we're piping to can't consume data as quickly as the one we're consuming +// from then large quantities of data may be held in memory. Instead the read +// stream can be passed to this method, the data will then be held on disk +// rather than in memory and will be cleaned up once it has been consumed. +// +export function bufferOnDisk(inStream, fileId, consumeOutStream, callback) { + if (consumeOutStream == null) { + consumeOutStream = function (fsPath, done) {} + } + if (callback == null) { + callback = function () {} + } + const timer = new metrics.Timer('LocalFileWriter.writeStream') + + // capture the stream url for logging + const url = inStream.uri && Url.format(inStream.uri) + + const fsPath = path.join(Settings.path.uploadFolder, uuid() + `-${fileId}`) + + let cleaningUp = false + const cleanup = _.once((streamError, res) => { + cleaningUp = true + return deleteFile(fsPath, function (cleanupError) { + if (streamError) { + OError.tag(streamError, 'error deleting temporary file', { + fsPath, + url, + }) + } + if (cleanupError) { + OError.tag(cleanupError) + } + if (streamError && cleanupError) { + // logging the cleanup error in case only the stream error is sent to the callback + logger.error(cleanupError) + } + return callback(streamError || cleanupError, res) + }) + }) + + logger.debug({ fsPath, url }, 'writing file locally') + + inStream.on('error', function (err) { + OError.tag(err, 'problem writing file locally, with read stream', { + fsPath, + url, + }) + return cleanup(err) + }) + + const writeStream = fs.createWriteStream(fsPath) + writeStream.on('error', function (err) { + OError.tag(err, 'problem writing file locally, with write stream', { + fsPath, + url, + }) + return cleanup(err) + }) + writeStream.on('finish', function () { + timer.done() + // in future check inStream.response.headers for hash value here + logger.debug({ fsPath, url }, 'stream closed after writing file locally') + if (!cleaningUp) { + const fileSize = writeStream.bytesWritten + return LargeFileManager.replaceWithStubIfNeeded( + fsPath, + fileId, + fileSize, + function (err, newFsPath) { + if (err != null) { + OError.tag(err, 'problem in large file manager', { + newFsPath, + fsPath, + fileId, + fileSize, + }) + return cleanup(err) + } + return consumeOutStream(newFsPath, cleanup) + } + ) + } + }) + return inStream.pipe(writeStream) +} + +export function deleteFile(fsPath, callback) { + if (fsPath == null || fsPath === '') { + return callback() + } + logger.debug({ fsPath }, 'removing local temp file') + return fs.unlink(fsPath, function (err) { + if (err != null && err.code !== 'ENOENT') { + // ignore errors deleting the file when it was never created + return callback(OError.tag(err)) + } else { + return callback() + } + }) +} diff --git a/services/project-history/app/js/LockManager.js b/services/project-history/app/js/LockManager.js new file mode 100644 index 0000000000..5ade60c99f --- /dev/null +++ b/services/project-history/app/js/LockManager.js @@ -0,0 +1,273 @@ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import async from 'async' +import metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import os from 'os' +import crypto from 'crypto' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' + +const LOCK_TEST_INTERVAL = 50 // 50ms between each test of the lock +const MAX_LOCK_WAIT_TIME = 10000 // 10s maximum time to spend trying to get the lock +export const LOCK_TTL = 360 // seconds +export const MIN_LOCK_EXTENSION_INTERVAL = 1000 // 1s minimum interval when extending a lock + +export const UNLOCK_SCRIPT = + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end' +const EXTEND_SCRIPT = + 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end' + +const HOST = os.hostname() +const PID = process.pid +const RND = crypto.randomBytes(4).toString('hex') +let COUNT = 0 + +const rclient = redis.createClient(Settings.redis.lock) + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +// Use a signed lock value as described in +// http://redis.io/topics/distlock#correct-implementation-with-a-single-instance +// to prevent accidental unlocking by multiple processes +_mocks.randomLock = () => { + const time = Date.now() + return `locked:host=${HOST}:pid=${PID}:random=${RND}:time=${time}:count=${COUNT++}` +} + +export function randomLock(...args) { + return _mocks.randomLock(...args) +} + +_mocks.tryLock = (key, callback) => { + if (callback == null) { + callback = function () {} + } + const lockValue = randomLock() + return rclient.set( + key, + lockValue, + 'EX', + LOCK_TTL, + 'NX', + function (err, gotLock) { + if (err != null) { + return callback(OError.tag(err)) + } + if (gotLock === 'OK') { + metrics.inc('lock.project.try.success') + return callback(err, true, lockValue) + } else { + metrics.inc('lock.project.try.failed') + return callback(err, false) + } + } + ) +} + +export function tryLock(...args) { + _mocks.tryLock(...args) +} + +_mocks.extendLock = (key, lockValue, callback) => { + if (callback == null) { + callback = function () {} + } + return rclient.eval( + EXTEND_SCRIPT, + 1, + key, + lockValue, + LOCK_TTL, + function (err, result) { + if (err != null) { + return callback(OError.tag(err)) + } + + if (result != null && result !== 1) { + // successful extension should release exactly one key + metrics.inc('lock.project.extend.failed') + const error = new OError('failed to extend lock', { + key, + lockValue, + result, + }) + return callback(error) + } + + metrics.inc('lock.project.extend.success') + return callback() + } + ) +} + +export function extendLock(...args) { + _mocks.extendLock(...args) +} + +_mocks.getLock = (key, callback) => { + let attempt + if (callback == null) { + callback = function () {} + } + const startTime = Date.now() + let attempts = 0 + return (attempt = function () { + if (Date.now() - startTime > MAX_LOCK_WAIT_TIME) { + metrics.inc('lock.project.get.failed') + const e = new OError('Timeout') + e.key = key + return callback(e) + } + + attempts += 1 + return tryLock(key, function (error, gotLock, lockValue) { + if (error != null) { + return callback(OError.tag(error)) + } + if (gotLock) { + metrics.gauge('lock.project.get.success.tries', attempts) + return callback(null, lockValue) + } else { + return setTimeout(attempt, LOCK_TEST_INTERVAL) + } + }) + })() +} + +export function getLock(...args) { + _mocks.getLock(...args) +} + +export function checkLock(key, callback) { + if (callback == null) { + callback = function () {} + } + return rclient.exists(key, function (err, exists) { + if (err != null) { + return callback(OError.tag(err)) + } + exists = parseInt(exists) + if (exists === 1) { + return callback(err, false) + } else { + return callback(err, true) + } + }) +} + +_mocks.releaseLock = (key, lockValue, callback) => { + return rclient.eval(UNLOCK_SCRIPT, 1, key, lockValue, function (err, result) { + if (err != null) { + return callback(OError.tag(err)) + } + if (result != null && result !== 1) { + // successful unlock should release exactly one key + const error = new OError('tried to release timed out lock', { + key, + lockValue, + redis_result: result, + }) + return callback(error) + } + return callback(err, result) + }) +} + +export function releaseLock(...args) { + _mocks.releaseLock(...args) +} + +export function runWithLock(key, runner, callback) { + if (callback == null) { + callback = function () {} + } + return getLock(key, function (error, lockValue) { + if (error != null) { + return callback(OError.tag(error)) + } + + const lock = new Lock(key, lockValue) + return runner(lock.extend.bind(lock), (error1, ...args) => + lock.release(function (error2) { + error = error1 || error2 + if (error != null) { + return callback(OError.tag(error), ...Array.from(args)) + } + return callback(null, ...Array.from(args)) + }) + ) + }) +} + +export function healthCheck(callback) { + const action = (extendLock, releaseLock) => releaseLock() + return runWithLock( + `HistoryLock:HealthCheck:host=${HOST}:pid=${PID}:random=${RND}`, + action, + callback + ) +} + +export function close(callback) { + rclient.quit() + return rclient.once('end', callback) +} + +class Lock { + constructor(key, value) { + this.key = key + this.value = value + this.slowExecutionError = new OError('slow execution during lock') + this.lockTakenAt = Date.now() + this.timer = new metrics.Timer('lock.project') + } + + extend(callback) { + const lockLength = Date.now() - this.lockTakenAt + if (lockLength < MIN_LOCK_EXTENSION_INTERVAL) { + return async.setImmediate(callback) + } + return extendLock(this.key, this.value, error => { + if (error != null) { + return callback(OError.tag(error)) + } + this.lockTakenAt = Date.now() + return callback() + }) + } + + release(callback) { + // The lock can expire in redis but the process carry on. This setTimout call + // is designed to log if this happens. + const lockLength = Date.now() - this.lockTakenAt + if (lockLength > LOCK_TTL * 1000) { + metrics.inc('lock.project.exceeded_lock_timeout') + logger.debug('exceeded lock timeout', { + key: this.key, + slowExecutionError: this.slowExecutionError, + }) + } + + return releaseLock(this.key, this.value, error => { + this.timer.done() + if (error != null) { + return callback(OError.tag(error)) + } + return callback() + }) + } +} diff --git a/services/project-history/app/js/OperationsCompressor.js b/services/project-history/app/js/OperationsCompressor.js new file mode 100644 index 0000000000..d14a4c8df1 --- /dev/null +++ b/services/project-history/app/js/OperationsCompressor.js @@ -0,0 +1,20 @@ +export function compressOperations(operations) { + if (!operations.length) return [] + + const newOperations = [] + let currentOperation = operations[0] + for (let operationId = 1; operationId < operations.length; operationId++) { + const nextOperation = operations[operationId] + if (currentOperation.canBeComposedWith(nextOperation)) { + currentOperation = currentOperation.compose(nextOperation) + } else { + // currentOperation and nextOperation cannot be composed. Push the + // currentOperation and start over with nextOperation. + newOperations.push(currentOperation) + currentOperation = nextOperation + } + } + newOperations.push(currentOperation) + + return newOperations +} diff --git a/services/project-history/app/js/Profiler.js b/services/project-history/app/js/Profiler.js new file mode 100644 index 0000000000..9b10552a50 --- /dev/null +++ b/services/project-history/app/js/Profiler.js @@ -0,0 +1,80 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' + +const LOG_CUTOFF_TIME = 1000 + +const deltaMs = function (ta, tb) { + const nanoSeconds = (ta[0] - tb[0]) * 1e9 + (ta[1] - tb[1]) + const milliSeconds = Math.floor(nanoSeconds * 1e-6) + return milliSeconds +} + +export class Profiler { + constructor(name, args) { + this.name = name + this.args = args + this.t0 = this.t = process.hrtime() + this.start = new Date() + this.updateTimes = [] + } + + log(label) { + const t1 = process.hrtime() + const dtMilliSec = deltaMs(t1, this.t) + this.t = t1 + this.updateTimes.push([label, dtMilliSec]) // timings in ms + return this // make it chainable + } + + end(message) { + const totalTime = deltaMs(this.t, this.t0) + // record the update times in metrics + for (const update of Array.from(this.updateTimes)) { + metrics.timing(`profile.${this.name}.${update[0]}`, update[1]) + } + if (totalTime > LOG_CUTOFF_TIME) { + // log anything greater than cutoff + const args = {} + for (const k in this.args) { + const v = this.args[k] + args[k] = v + } + args.updateTimes = this.updateTimes + args.start = this.start + args.end = new Date() + logger.debug(args, this.name) + } + return totalTime + } + + getTimeDelta() { + const lastIdx = this.updateTimes.length - 1 + if (lastIdx >= 0) { + return this.updateTimes[lastIdx][1] + } else { + return 0 + } + } + + wrap(label, fn) { + // create a wrapped function which calls profile.log(label) before continuing execution + const newFn = (...args) => { + this.log(label) + return fn(...Array.from(args || [])) + } + return newFn + } +} diff --git a/services/project-history/app/js/RedisManager.js b/services/project-history/app/js/RedisManager.js new file mode 100644 index 0000000000..9578bad3ed --- /dev/null +++ b/services/project-history/app/js/RedisManager.js @@ -0,0 +1,455 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { promisify } from 'util' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import async from 'async' +import redis from '@overleaf/redis-wrapper' +import metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' + +// maximum size taken from the redis queue, to prevent project history +// consuming unbounded amounts of memory +let RAW_UPDATE_SIZE_THRESHOLD = 4 * 1024 * 1024 + +// maximum length of ops (insertion and deletions) to process in a single +// iteration +let MAX_UPDATE_OP_LENGTH = 1024 + +// warn if we exceed this raw update size, the final compressed updates we send +// could be smaller than this +const WARN_RAW_UPDATE_SIZE = 1024 * 1024 + +// maximum number of new docs to process in a single iteration +let MAX_NEW_DOC_CONTENT_COUNT = 32 + +const CACHE_TTL_IN_SECONDS = 3600 + +const Keys = Settings.redis.project_history.key_schema +const rclient = redis.createClient(Settings.redis.project_history) + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +export function countUnprocessedUpdates(project_id, callback) { + const key = Keys.projectHistoryOps({ project_id }) + return rclient.llen(key, callback) +} + +_mocks.getOldestDocUpdates = (project_id, batch_size, callback) => { + if (callback == null) { + callback = function () {} + } + const key = Keys.projectHistoryOps({ project_id }) + rclient.lrange(key, 0, batch_size - 1, callback) +} + +export function getOldestDocUpdates(...args) { + _mocks.getOldestDocUpdates(...args) +} + +_mocks.parseDocUpdates = (json_updates, callback) => { + let parsed_updates + if (callback == null) { + callback = function () {} + } + try { + parsed_updates = Array.from(json_updates || []).map(update => + JSON.parse(update) + ) + } catch (e) { + return callback(e) + } + callback(null, parsed_updates) +} + +export function parseDocUpdates(...args) { + _mocks.parseDocUpdates(...args) +} + +export function getUpdatesInBatches(project_id, batch_size, runner, callback) { + let moreBatches = true + let lastResults = [] + + const processBatch = cb => + getOldestDocUpdates(project_id, batch_size, function (error, raw_updates) { + let raw_update + if (error != null) { + return cb(OError.tag(error)) + } + moreBatches = raw_updates.length === batch_size + if (raw_updates.length === 0) { + return cb() + } + // don't process any more batches if we are single stepping + if (batch_size === 1) { + moreBatches = false + } + + // consume the updates up to a maximum total number of bytes + // ensuring that at least one update will be processed (we may + // exceed RAW_UPDATE_SIZE_THRESHOLD is the first update is bigger + // than that). + let total_raw_updates_size = 0 + const updates_to_process = [] + for (raw_update of Array.from(raw_updates)) { + const next_total_size = total_raw_updates_size + raw_update.length + if ( + updates_to_process.length > 0 && + next_total_size > RAW_UPDATE_SIZE_THRESHOLD + ) { + // stop consuming updates if we have at least one and the + // next update would exceed the size threshold + break + } else { + updates_to_process.push(raw_update) + total_raw_updates_size += raw_update.length + } + } + + // if we hit the size limit above, only process the updates up to that point + if (updates_to_process.length < raw_updates.length) { + moreBatches = true // process remaining raw updates in the next iteration + raw_updates = updates_to_process + } + + metrics.timing('redis.incoming.bytes', total_raw_updates_size, 1) + if (total_raw_updates_size > WARN_RAW_UPDATE_SIZE) { + const raw_update_sizes = (() => { + const result = [] + for (raw_update of Array.from(raw_updates)) { + result.push(raw_update.length) + } + return result + })() + logger.warn( + { project_id, total_raw_updates_size, raw_update_sizes }, + 'large raw update size' + ) + } + + return parseDocUpdates(raw_updates, function (error, updates) { + if (error != null) { + OError.tag(error, 'failed to parse updates', { + project_id, + updates, + }) + return cb(error) + } + + // consume the updates up to a maximum number of ops (insertions and deletions) + let total_op_length = 0 + let updates_to_process_count = 0 + let total_doc_content_count = 0 + for (const parsed_update of Array.from(updates)) { + if (parsed_update.resyncDocContent) { + total_doc_content_count++ + } + if (total_doc_content_count > MAX_NEW_DOC_CONTENT_COUNT) { + break + } + const next_total_op_length = + total_op_length + (parsed_update?.op?.length || 1) + if ( + updates_to_process_count > 0 && + next_total_op_length > MAX_UPDATE_OP_LENGTH + ) { + break + } else { + total_op_length = next_total_op_length + updates_to_process_count++ + } + } + + // if we hit the op limit above, only process the updates up to that point + if (updates_to_process_count < updates.length) { + logger.debug( + { + project_id, + updates_to_process_count, + updates_count: updates.length, + total_op_length, + }, + 'restricting number of ops to be processed' + ) + moreBatches = true + // there is a 1:1 mapping between raw_updates and updates + // which we need to preserve here to ensure we only + // delete the updates that are actually processed + raw_updates = raw_updates.slice(0, updates_to_process_count) + updates = updates.slice(0, updates_to_process_count) + } + + logger.debug({ project_id }, 'retrieved raw updates from redis') + return runner(updates, function (error, ...args) { + lastResults = args + if (error != null) { + return cb(OError.tag(error)) + } + return deleteAppliedDocUpdates(project_id, raw_updates, cb) + }) + }) + }) + + const hasMoreBatches = (...args) => { + const cb = args[args.length - 1] + return cb(null, moreBatches) + } + + return async.doWhilst(processBatch, hasMoreBatches, error => + callback(error, ...Array.from(lastResults)) + ) +} + +_mocks.deleteAppliedDocUpdates = (project_id, updates, callback) => { + if (callback == null) { + callback = function () {} + } + const multi = rclient.multi() + // Delete all the updates which have been applied (exact match) + for (const update of Array.from(updates || [])) { + // Delete the first occurrence of the update with LREM KEY COUNT + // VALUE by setting COUNT to 1 which 'removes COUNT elements equal to + // value moving from head to tail.' + // + // If COUNT is 0 the entire list would be searched which would block + // redis snce it would be an O(N) operation where N is the length of + // the queue, in a multi of the batch size. + metrics.summary('redis.projectHistoryOps', update.length, { + status: 'lrem', + }) + multi.lrem(Keys.projectHistoryOps({ project_id }), 1, update) + } + multi.exec(callback) +} + +export function deleteAppliedDocUpdates(...args) { + _mocks.deleteAppliedDocUpdates(...args) +} + +export function destroyDocUpdatesQueue(project_id, callback) { + // deletes the entire queue - use with caution + if (callback == null) { + callback = function () {} + } + return rclient.del(Keys.projectHistoryOps({ project_id }), callback) +} + +// iterate over keys asynchronously using redis scan (non-blocking) +// handle all the cluster nodes or single redis server +function _getKeys(pattern, limit, callback) { + const nodes = (typeof rclient.nodes === 'function' + ? rclient.nodes('master') + : undefined) || [rclient] + const doKeyLookupForNode = (node, cb) => + _getKeysFromNode(node, pattern, limit, cb) + return async.concatSeries(nodes, doKeyLookupForNode, callback) +} + +function _getKeysFromNode(node, pattern, limit, callback) { + let cursor = 0 // redis iterator + const keySet = {} // use hash to avoid duplicate results + const batchSize = limit != null ? Math.min(limit, 1000) : 1000 + // scan over all keys looking for pattern + const doIteration = ( + cb // avoid hitting redis too hard + ) => + node.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + batchSize, + function (error, reply) { + let keys + if (error != null) { + return callback(OError.tag(error)) + } + ;[cursor, keys] = Array.from(reply) + for (const key of Array.from(keys)) { + keySet[key] = true + } + keys = Object.keys(keySet) + const noResults = cursor === '0' // redis returns string results not numeric + const limitReached = limit != null && keys.length >= limit + if (noResults || limitReached) { + return callback(null, keys) + } else { + return setTimeout(doIteration, 10) + } + } + ) + return doIteration() +} + +// extract ids from keys like DocsWithHistoryOps:57fd0b1f53a8396d22b2c24b +// or DocsWithHistoryOps:{57fd0b1f53a8396d22b2c24b} (for redis cluster) +function _extractIds(keyList) { + const ids = (() => { + const result = [] + for (const key of Array.from(keyList)) { + const m = key.match(/:\{?([0-9a-f]{24})\}?/) // extract object id + result.push(m[1]) + } + return result + })() + return ids +} + +export function getProjectIdsWithHistoryOps(limit, callback) { + if (callback == null) { + callback = function () {} + } + return _getKeys( + Keys.projectHistoryOps({ project_id: '*' }), + limit, + function (error, project_keys) { + if (error != null) { + return callback(OError.tag(error)) + } + const project_ids = _extractIds(project_keys) + return callback(error, project_ids) + } + ) +} + +export function getProjectIdsWithHistoryOpsCount(callback) { + if (callback == null) { + callback = function () {} + } + return getProjectIdsWithHistoryOps(null, function (error, projectIds) { + if (error != null) { + return callback(OError.tag(error)) + } + const queuedProjectsCount = projectIds.length + metrics.globalGauge('queued-projects', queuedProjectsCount) + return callback(null, queuedProjectsCount) + }) +} + +export function setFirstOpTimestamp(project_id, callback) { + if (callback == null) { + callback = function () {} + } + const key = Keys.projectHistoryFirstOpTimestamp({ project_id }) + // store current time as an integer (string) + return rclient.setnx(key, Date.now(), callback) +} + +export function getFirstOpTimestamp(project_id, callback) { + if (callback == null) { + callback = function () {} + } + const key = Keys.projectHistoryFirstOpTimestamp({ project_id }) + return rclient.get(key, function (err, result) { + if (err != null) { + return callback(OError.tag(err)) + } + // convert stored time back to a numeric timestamp + const timestamp = parseInt(result, 10) + // check for invalid timestamp + if (isNaN(timestamp)) { + return callback() + } + // convert numeric timestamp to a date object + const firstOpTimestamp = new Date(timestamp) + return callback(null, firstOpTimestamp) + }) +} + +export function clearFirstOpTimestamp(project_id, callback) { + if (callback == null) { + callback = function () {} + } + const key = Keys.projectHistoryFirstOpTimestamp({ project_id }) + return rclient.del(key, callback) +} + +export function getProjectIdsWithFirstOpTimestamps(limit, callback) { + return _getKeys( + Keys.projectHistoryFirstOpTimestamp({ project_id: '*' }), + limit, + function (error, project_keys) { + if (error != null) { + return callback(OError.tag(error)) + } + const project_ids = _extractIds(project_keys) + return callback(error, project_ids) + } + ) +} + +export function clearDanglingFirstOpTimestamp(project_id, callback) { + rclient.exists( + Keys.projectHistoryFirstOpTimestamp({ project_id }), + Keys.projectHistoryOps({ project_id }), + function (error, count) { + if (error) { + return callback(error) + } + if (count === 2 || count === 0) { + // both (or neither) keys are present, so don't delete the timestamp + return callback(null, 0) + } + // only one key is present, which makes this a dangling record, + // so delete the timestamp + rclient.del(Keys.projectHistoryFirstOpTimestamp({ project_id }), callback) + } + ) +} + +export function getCachedHistoryId(project_id, callback) { + const key = Keys.projectHistoryCachedHistoryId({ project_id }) + rclient.get(key, function (err, historyId) { + if (err) { + return callback(OError.tag(err)) + } + callback(null, historyId) + }) +} + +export function setCachedHistoryId(project_id, historyId, callback) { + const key = Keys.projectHistoryCachedHistoryId({ project_id }) + rclient.setex(key, CACHE_TTL_IN_SECONDS, historyId, callback) +} + +export function clearCachedHistoryId(project_id, callback) { + const key = Keys.projectHistoryCachedHistoryId({ project_id }) + rclient.del(key, callback) +} + +// for tests +export function setMaxUpdateOpLength(value) { + MAX_UPDATE_OP_LENGTH = value +} + +export function setRawUpdateSizeThreshold(value) { + RAW_UPDATE_SIZE_THRESHOLD = value +} + +export function setMaxNewDocContentCount(value) { + MAX_NEW_DOC_CONTENT_COUNT = value +} + +export const promises = { + countUnprocessedUpdates: promisify(countUnprocessedUpdates), + getProjectIdsWithFirstOpTimestamps: promisify( + getProjectIdsWithFirstOpTimestamps + ), + clearDanglingFirstOpTimestamp: promisify(clearDanglingFirstOpTimestamp), +} diff --git a/services/project-history/app/js/RetryManager.js b/services/project-history/app/js/RetryManager.js new file mode 100644 index 0000000000..4acefa5ea2 --- /dev/null +++ b/services/project-history/app/js/RetryManager.js @@ -0,0 +1,194 @@ +import _ from 'lodash' +import { promisify, callbackify } from 'util' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as SyncManager from './SyncManager.js' +import * as WebApiManager from './WebApiManager.js' +import * as RedisManager from './RedisManager.js' +import * as ErrorRecorder from './ErrorRecorder.js' + +const sleep = promisify(setTimeout) + +const TEMPORARY_FAILURES = [ + 'Error: ENOSPC: no space left on device, write', + 'Error: ESOCKETTIMEDOUT', + 'Error: failed to extend lock', + 'Error: tried to release timed out lock', + 'Error: Timeout', +] + +const HARD_FAILURES = [ + 'Error: history store a non-success status code: 422', + 'OpsOutOfOrderError: project structure version out of order', + 'OpsOutOfOrderError: project structure version out of order on incoming updates', + 'OpsOutOfOrderError: doc version out of order', + 'OpsOutOfOrderError: doc version out of order on incoming updates', +] + +const MAX_RESYNC_ATTEMPTS = 2 +const MAX_SOFT_RESYNC_ATTEMPTS = 1 + +export const promises = {} + +promises.retryFailures = async (options = {}) => { + const { failureType, timeout, limit } = options + if (failureType === 'soft') { + const batch = await getFailureBatch(softErrorSelector, limit) + const result = await retryFailureBatch(batch, timeout, async failure => { + await UpdatesProcessor.promises.processUpdatesForProject( + failure.project_id + ) + }) + return result + } else if (failureType === 'hard') { + const batch = await getFailureBatch(hardErrorSelector, limit) + const result = await retryFailureBatch(batch, timeout, async failure => { + await resyncProject(failure.project_id, { + hard: failureRequiresHardResync(failure), + }) + }) + return result + } +} + +export const retryFailures = callbackify(promises.retryFailures) + +function softErrorSelector(failure) { + return ( + (isTemporaryFailure(failure) && !isRepeatedFailure(failure)) || + (isFirstFailure(failure) && !isHardFailure(failure)) + ) +} + +function hardErrorSelector(failure) { + return ( + (isHardFailure(failure) || isRepeatedFailure(failure)) && + !isStuckFailure(failure) + ) +} + +function isTemporaryFailure(failure) { + return TEMPORARY_FAILURES.includes(failure.error) +} + +function isHardFailure(failure) { + return HARD_FAILURES.includes(failure.error) +} + +function isFirstFailure(failure) { + return failure.attempts <= 1 +} + +function isRepeatedFailure(failure) { + return failure.attempts > 3 +} + +function isStuckFailure(failure) { + return ( + failure.resyncAttempts != null && + failure.resyncAttempts >= MAX_RESYNC_ATTEMPTS + ) +} + +function failureRequiresHardResync(failure) { + return ( + failure.resyncAttempts != null && + failure.resyncAttempts >= MAX_SOFT_RESYNC_ATTEMPTS + ) +} + +async function getFailureBatch(selector, limit) { + let failures = await ErrorRecorder.promises.getFailedProjects() + failures = failures.filter(selector) + // randomise order + failures = _.shuffle(failures) + + // put a limit on the number to retry + const projectsToRetryCount = failures.length + if (limit && projectsToRetryCount > limit) { + failures = failures.slice(0, limit) + } + logger.debug({ projectsToRetryCount, limit }, 'retrying failed projects') + return failures +} + +async function retryFailureBatch(failures, timeout, retryHandler) { + const startTime = new Date() + + // keep track of successes and failures + const failed = [] + const succeeded = [] + for (const failure of failures) { + const projectId = failure.project_id + const timeTaken = new Date() - startTime + if (timeout && timeTaken > timeout) { + // finish early due to timeout + logger.debug('background retries timed out') + break + } + logger.debug( + { projectId, timeTaken }, + 'retrying failed project in background' + ) + try { + await retryHandler(failure) + succeeded.push(projectId) + } catch (err) { + failed.push(projectId) + } + } + return { succeeded, failed } +} + +async function resyncProject(projectId, options = {}) { + const { hard = false } = options + try { + if (!/^[0-9a-f]{24}$/.test(projectId)) { + logger.debug({ projectId }, 'clearing bad project id') + await ErrorRecorder.promises.record(projectId, 0, null) + return + } + + await checkProjectHasHistoryId(projectId) + if (hard) { + await SyncManager.promises.startHardResync(projectId) + } else { + await SyncManager.promises.startResync(projectId) + } + await waitUntilRedisQueueIsEmpty(projectId) + await checkFailureRecordWasRemoved(projectId) + } catch (err) { + throw new OError({ + message: 'failed to resync project', + info: { projectId, hard }, + }).withCause(err) + } +} + +async function checkProjectHasHistoryId(projectId) { + const historyId = await WebApiManager.promises.getHistoryId(projectId) + if (historyId == null) { + throw new OError('no history id') + } +} + +async function waitUntilRedisQueueIsEmpty(projectId) { + for (let attempts = 0; attempts < 30; attempts++) { + const updatesCount = await RedisManager.promises.countUnprocessedUpdates( + projectId + ) + if (updatesCount === 0) { + return + } + await sleep(1000) + } + throw new OError('queue not empty') +} + +async function checkFailureRecordWasRemoved(projectId) { + const failureRecord = await ErrorRecorder.promises.getFailureRecord(projectId) + if (failureRecord) { + throw new OError('failure record still exists') + } +} diff --git a/services/project-history/app/js/Router.js b/services/project-history/app/js/Router.js new file mode 100644 index 0000000000..4eca0effc4 --- /dev/null +++ b/services/project-history/app/js/Router.js @@ -0,0 +1,201 @@ +import OError from '@overleaf/o-error' +import * as HttpController from './HttpController.js' +import { Joi, validate } from './Validation.js' + +export function initialize(app) { + app.use( + validate({ + params: Joi.object({ + project_id: Joi.string().regex(/^[0-9a-f]{24}$/), + user_id: Joi.string().regex(/^[0-9a-f]{24}$/), + label_id: Joi.string().regex(/^[0-9a-f]{24}$/), + version: Joi.number().integer(), + }), + }) + ) + + // use an extended timeout on all endpoints, to allow for long requests to history-v1 + app.use(longerTimeout) + + app.post('/project', HttpController.initializeProject) + + app.delete('/project/:project_id', HttpController.deleteProject) + + app.get( + '/project/:project_id/diff', + validate({ + query: { + pathname: Joi.string().required(), + from: Joi.number().integer().required(), + to: Joi.number().integer().required(), + }, + }), + HttpController.getDiff + ) + + app.get( + '/project/:project_id/filetree/diff', + validate({ + query: { + from: Joi.number().integer().required(), + to: Joi.number().integer().required(), + }, + }), + HttpController.getFileTreeDiff + ) + + app.get( + '/project/:project_id/updates', + validate({ + query: { + before: Joi.number().integer(), + min_count: Joi.number().integer(), + }, + }), + HttpController.getUpdates + ) + + app.get('/project/:project_id/version', HttpController.latestVersion) + + app.post( + '/project/:project_id/flush', + validate({ + query: { + debug: Joi.boolean().default(false), + bisect: Joi.boolean().default(false), + }, + }), + HttpController.flushProject + ) + + app.post( + '/project/:project_id/resync', + validate({ + query: { + force: Joi.boolean().default(false), + }, + body: { + force: Joi.boolean().default(false), + origin: Joi.object({ + kind: Joi.string().required(), + }), + }, + }), + HttpController.resyncProject + ) + + app.get( + '/project/:project_id/dump', + validate({ + query: { + count: Joi.number().integer(), + }, + }), + HttpController.dumpProject + ) + + app.get('/project/:project_id/labels', HttpController.getLabels) + + app.post( + '/project/:project_id/user/:user_id/labels', + validate({ + body: { + version: Joi.number().integer().required(), + comment: Joi.string().required(), + created_at: Joi.string(), + validate_exists: Joi.boolean().default(true), + }, + }), + + HttpController.createLabel + ) + + app.delete( + '/project/:project_id/user/:user_id/labels/:label_id', + HttpController.deleteLabel + ) + + app.post( + '/user/:from_user/labels/transfer/:to_user', + HttpController.transferLabels + ) + + app.get( + '/project/:project_id/version/:version/:pathname', + HttpController.getFileSnapshot + ) + + app.get( + '/project/:project_id/version/:version', + HttpController.getProjectSnapshot + ) + + app.post( + '/project/:project_id/force', + validate({ + query: { + clear: Joi.boolean().default(false), + }, + }), + HttpController.forceDebugProject + ) + + app.get('/project/:project_id/blob/:hash', HttpController.getProjectBlob) + + app.get('/status/failures', HttpController.getFailures) + + app.get('/status/queue', HttpController.getQueueCounts) + + app.post( + '/retry/failures', + validate({ + query: { + failureType: Joi.string().valid('soft', 'hard'), + // bail out after this time limit + timeout: Joi.number().integer().default(300), + // maximum number of projects to check + limit: Joi.number().integer().default(100), + callbackUrl: Joi.string(), + }, + }), + HttpController.retryFailures + ) + + app.post( + '/flush/old', + validate({ + query: { + // flush projects with queued ops older than this + maxAge: Joi.number() + .integer() + .default(6 * 3600), + // pause this amount of time between checking queues + queueDelay: Joi.number().integer().default(100), + // maximum number of queues to check + limit: Joi.number().integer().default(1000), + // maximum amount of time allowed + timeout: Joi.number() + .integer() + .default(60 * 1000), + // whether to run in the background + background: Joi.boolean().falsy('0').truthy('1').default(false), + }, + }), + HttpController.flushOld + ) + + app.get('/status', (req, res, next) => res.send('project-history is up')) + + app.get('/oops', function (req, res, next) { + throw new OError('dummy test error') + }) + + app.get('/check_lock', HttpController.checkLock) + + app.get('/health_check', HttpController.healthCheck) +} + +function longerTimeout(req, res, next) { + res.setTimeout(6 * 60 * 1000) + next() +} diff --git a/services/project-history/app/js/SnapshotManager.js b/services/project-history/app/js/SnapshotManager.js new file mode 100644 index 0000000000..1ebaab143b --- /dev/null +++ b/services/project-history/app/js/SnapshotManager.js @@ -0,0 +1,146 @@ +import Core from 'overleaf-editor-core' +import { Readable as StringStream } from 'stream' +import BPromise from 'bluebird' +import OError from '@overleaf/o-error' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as WebApiManager from './WebApiManager.js' +import * as Errors from './Errors.js' + +StringStream.prototype._read = function () {} + +const MAX_REQUESTS = 4 // maximum number of parallel requests to v1 history service + +export function getFileSnapshotStream(projectId, version, pathname, callback) { + _getSnapshotAtVersion(projectId, version, (error, snapshot) => { + if (error) { + return callback(OError.tag(error)) + } + const file = snapshot.getFile(pathname) + if (file == null) { + error = new Errors.NotFoundError(`${pathname} not found`, { + projectId, + version, + pathname, + }) + return callback(error) + } + + WebApiManager.getHistoryId(projectId, (err, historyId) => { + if (err) { + return callback(OError.tag(err)) + } + if (file.isEditable()) { + file + .load('eager', HistoryStoreManager.getBlobStore(historyId)) + .then(() => { + const stream = new StringStream() + stream.push(file.getContent()) + stream.push(null) + callback(null, stream) + }) + .catch(err => callback(err)) + } else { + HistoryStoreManager.getProjectBlobStream( + historyId, + file.getHash(), + callback + ) + } + }) + }) +} + +// Returns project snapshot containing the document content for files with +// text operations in the relevant chunk, and hashes for unmodified/binary +// files. Used by git bridge to get the state of the project. +export function getProjectSnapshot(projectId, version, callback) { + _getSnapshotAtVersion(projectId, version, (error, snapshot) => { + if (error) { + return callback(OError.tag(error)) + } + WebApiManager.getHistoryId(projectId, (err, historyId) => { + if (err) { + return callback(OError.tag(err)) + } + _loadFilesLimit( + snapshot, + 'eager', + HistoryStoreManager.getBlobStore(historyId) + ) + .then(() => { + const data = { + projectId, + files: snapshot.getFileMap().files, + } + callback(null, data) + }) + .catch(callback) + }) + }) +} + +function _getSnapshotAtVersion(projectId, version, callback) { + WebApiManager.getHistoryId(projectId, (error, historyId) => { + if (error) { + return callback(OError.tag(error)) + } + HistoryStoreManager.getChunkAtVersion( + projectId, + historyId, + version, + (error, data) => { + if (error) { + return callback(OError.tag(error)) + } + const chunk = Core.Chunk.fromRaw(data.chunk) + const snapshot = chunk.getSnapshot() + const changes = chunk + .getChanges() + .slice(0, version - chunk.getStartVersion()) + snapshot.applyAll(changes) + callback(null, snapshot) + } + ) + }) +} + +export function getLatestSnapshot(projectId, historyId, callback) { + HistoryStoreManager.getMostRecentChunk(projectId, historyId, (err, data) => { + if (err) { + return callback(err) + } + if (data == null || data.chunk == null) { + return callback(new OError('undefined chunk')) + } + // apply all the changes in the chunk to get the current snapshot + const chunk = Core.Chunk.fromRaw(data.chunk) + const snapshot = chunk.getSnapshot() + const changes = chunk.getChanges() + snapshot.applyAll(changes) + snapshot + .loadFiles('lazy', HistoryStoreManager.getBlobStore(historyId)) + .then(snapshotFiles => callback(null, snapshotFiles)) + .catch(err => callback(err)) + }) +} + +function _loadFilesLimit(snapshot, kind, blobStore) { + // bluebird promises only support a limit on concurrency for map() + // so make an array of the files we need to load + const fileList = [] + snapshot.fileMap.map(file => fileList.push(file)) + // load the files in parallel with a limit on the concurrent requests + return BPromise.map( + fileList, + file => { + // only load changed files, others can be dereferenced from their + // blobs (this method is only used by the git bridge which + // understands how to load blobs). + if (!file.isEditable() || file.getHash()) { + return + } + return file.load(kind, blobStore) + }, + { concurrency: MAX_REQUESTS } + ) +} diff --git a/services/project-history/app/js/SummarizedUpdatesManager.js b/services/project-history/app/js/SummarizedUpdatesManager.js new file mode 100644 index 0000000000..a539e4aac6 --- /dev/null +++ b/services/project-history/app/js/SummarizedUpdatesManager.js @@ -0,0 +1,320 @@ +import _ from 'lodash' +import async from 'async' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import * as ChunkTranslator from './ChunkTranslator.js' +import * as HistoryApiManager from './HistoryApiManager.js' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as LabelsManager from './LabelsManager.js' +import * as UpdatesProcessor from './UpdatesProcessor.js' +import * as WebApiManager from './WebApiManager.js' + +const MAX_CHUNK_REQUESTS = 5 +const TIME_BETWEEN_DISTINCT_UPDATES = 5 * 60 * 1000 // five minutes + +export function getSummarizedProjectUpdates(projectId, options, callback) { + // Some notes on versions: + // + // Versions of the project are like the fenceposts between updates. + // An update applies to a certain version of the project, and gives us the + // next version. + // + // When we ask for updates 'before' a version, this includes the update + // that created the version equal to 'before'. + // + // A chunk in OL has a 'startVersion', which is the version of the project + // before any of the updates in it were applied. This is the same version as + // the last update in the previous chunk would have created. + // + // If we ask the OL history store for the chunk with version that is the end of one + // chunk and the start of another, it will return the older chunk, i.e. + // the chunk with the updates that led up to that version. + // + // So once we read in the updates from a chunk, and want to get the updates from + // the previous chunk, we ask OL for the chunk with the version equal to the + // 'startVersion' of the newer chunk we just read. + + let nextVersionToRequest + if (options == null) { + options = {} + } + if (!options.min_count) { + options.min_count = 25 + } + if (options.before != null) { + // The version is of the doc, so we want the updates before that version, + // which includes the update that created that version. + nextVersionToRequest = options.before + } else { + // Return the latest updates first if no nextVersionToRequest is set. + nextVersionToRequest = null + } + + UpdatesProcessor.processUpdatesForProject(projectId, function (error) { + if (error) { + return callback(OError.tag(error)) + } + LabelsManager.getLabels(projectId, function (error, labels) { + if (error) { + return callback(OError.tag(error)) + } + + const labelsByVersion = {} + for (const label of labels) { + if (labelsByVersion[label.version] == null) { + labelsByVersion[label.version] = [] + } + labelsByVersion[label.version].push(label) + } + + WebApiManager.getHistoryId(projectId, function (error, historyId) { + if (error) return callback(error) + let chunksRequested = 0 + let summarizedUpdates = [] + + const shouldRequestMoreUpdates = cb => { + return cb( + null, + chunksRequested < MAX_CHUNK_REQUESTS && + (nextVersionToRequest == null || nextVersionToRequest > 0) && + summarizedUpdates.length < options.min_count + ) + } + + const getNextBatchOfUpdates = cb => + _getProjectUpdates( + projectId, + historyId, + nextVersionToRequest, + function (error, updateSet, startVersion) { + if (error) { + return cb(OError.tag(error)) + } + // Updates are returned in time order, but we want to go back in time + updateSet.reverse() + updateSet = discardUnwantedUpdates(updateSet) + summarizedUpdates = _summarizeUpdates( + updateSet, + labelsByVersion, + summarizedUpdates + ) + nextVersionToRequest = startVersion + chunksRequested += 1 + cb() + } + ) + + function discardUnwantedUpdates(updateSet) { + // We're getting whole chunks from the OL history store, but we might + // only want updates from before a certain version + if (options.before == null) { + return updateSet + } else { + return updateSet.filter(u => u.v < options.before) + } + } + + // If the project doesn't have a history then we can bail out here + HistoryApiManager.shouldUseProjectHistory( + projectId, + function (error, shouldUseProjectHistory) { + if (error) { + return callback(OError.tag(error)) + } + if (shouldUseProjectHistory) { + async.whilst( + shouldRequestMoreUpdates, + getNextBatchOfUpdates, + function (error) { + if (error) { + return callback(OError.tag(error)) + } + callback( + null, + summarizedUpdates, + nextVersionToRequest > 0 ? nextVersionToRequest : undefined + ) + } + ) + } else { + logger.debug( + { projectId }, + 'returning no updates as project does not use history' + ) + callback(null, []) + } + } + ) + }) + }) + }) +} + +function _getProjectUpdates(projectId, historyId, version, callback) { + function getChunk(cb) { + if (version != null) { + HistoryStoreManager.getChunkAtVersion(projectId, historyId, version, cb) + } else { + HistoryStoreManager.getMostRecentChunk(projectId, historyId, cb) + } + } + + getChunk(function (error, chunk) { + if (error) { + return callback(OError.tag(error)) + } + const oldestVersion = chunk.chunk.startVersion + ChunkTranslator.convertToSummarizedUpdates( + chunk, + function (error, updateSet) { + if (error) { + return callback(OError.tag(error)) + } + callback(error, updateSet, oldestVersion) + } + ) + }) +} + +function _summarizeUpdates(updates, labels, existingSummarizedUpdates) { + if (existingSummarizedUpdates == null) { + existingSummarizedUpdates = [] + } + const summarizedUpdates = existingSummarizedUpdates.slice() + for (const update of updates) { + // The client needs to know the exact version that a delete happened, in order + // to be able to restore. So even when summarizing, retain the version that each + // projectOp happened at. + for (const projectOp of update.project_ops) { + projectOp.atV = update.v + } + + const summarizedUpdate = summarizedUpdates[summarizedUpdates.length - 1] + const labelsForVersion = labels[update.v + 1] || [] + if ( + summarizedUpdate && + _shouldMergeUpdate(update, summarizedUpdate, labelsForVersion) + ) { + _mergeUpdate(update, summarizedUpdate) + } else { + const newUpdate = { + fromV: update.v, + toV: update.v + 1, + meta: { + users: update.meta.users, + start_ts: update.meta.start_ts, + end_ts: update.meta.end_ts, + }, + labels: labelsForVersion, + pathnames: new Set(update.pathnames), + project_ops: update.project_ops.slice(), // Clone since we'll modify + } + if (update.meta.origin) { + newUpdate.meta.origin = update.meta.origin + } + + summarizedUpdates.push(newUpdate) + } + } + + return summarizedUpdates +} + +/** + * Given an update, the latest summarized update, and the labels that apply to + * the update, figure out if we can merge the update into the summarized + * update. + */ +function _shouldMergeUpdate(update, summarizedUpdate, labels) { + // Split updates on labels + if (labels.length > 0) { + return false + } + + // Split updates on origin + if (update.meta.origin) { + if (summarizedUpdate.meta.origin) { + if (update.meta.origin.kind !== summarizedUpdate.meta.origin.kind) { + return false + } + } else { + return false + } + } else if (summarizedUpdate.meta.origin) { + return false + } + + // Split updates if it's been too long since the last update. We're going + // backwards in time through the updates, so the update comes before the summarized update. + if ( + summarizedUpdate.meta.end_ts - update.meta.start_ts >= + TIME_BETWEEN_DISTINCT_UPDATES + ) { + return false + } + + // Do not merge text operations and file operations, except for history resyncs + const updateHasTextOps = update.pathnames.length > 0 + const updateHasFileOps = update.project_ops.length > 0 + const summarizedUpdateHasTextOps = summarizedUpdate.pathnames.size > 0 + const summarizedUpdateHasFileOps = summarizedUpdate.project_ops.length > 0 + const isHistoryResync = + update.meta.origin && + ['history-resync', 'history-migration'].includes(update.meta.origin.kind) + if ( + !isHistoryResync && + ((updateHasTextOps && summarizedUpdateHasFileOps) || + (updateHasFileOps && summarizedUpdateHasTextOps)) + ) { + return false + } + + return true +} + +/** + * Merge an update into a summarized update. + * + * This mutates the summarized update. + */ +function _mergeUpdate(update, summarizedUpdate) { + // check if the user in this update is already present in the earliest update, + // if not, add them to the users list of the earliest update + summarizedUpdate.meta.users = _.uniqBy( + _.union(summarizedUpdate.meta.users, update.meta.users), + function (user) { + if (user == null) { + return null + } + if (user.id == null) { + return user + } + return user.id + } + ) + + summarizedUpdate.fromV = Math.min(summarizedUpdate.fromV, update.v) + summarizedUpdate.toV = Math.max(summarizedUpdate.toV, update.v + 1) + summarizedUpdate.meta.start_ts = Math.min( + summarizedUpdate.meta.start_ts, + update.meta.start_ts + ) + summarizedUpdate.meta.end_ts = Math.max( + summarizedUpdate.meta.end_ts, + update.meta.end_ts + ) + + // Add file operations + for (const op of update.project_ops || []) { + summarizedUpdate.project_ops.push(op) + if (op.add) { + // Merging a file creation. Remove any corresponding edit since that's redundant. + summarizedUpdate.pathnames.delete(op.add.pathname) + } + } + + // Add edit operations + for (const pathname of update.pathnames || []) { + summarizedUpdate.pathnames.add(pathname) + } +} diff --git a/services/project-history/app/js/SyncManager.js b/services/project-history/app/js/SyncManager.js new file mode 100644 index 0000000000..9eefa258b9 --- /dev/null +++ b/services/project-history/app/js/SyncManager.js @@ -0,0 +1,745 @@ +import _ from 'lodash' +import async from 'async' +import { promisify } from 'util' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' +import { File } from 'overleaf-editor-core' +import { SyncError } from './Errors.js' +import { db, ObjectId } from './mongodb.js' +import * as SnapshotManager from './SnapshotManager.js' +import * as LockManager from './LockManager.js' +import * as UpdateTranslator from './UpdateTranslator.js' +import * as UpdateCompressor from './UpdateCompressor.js' +import * as WebApiManager from './WebApiManager.js' +import * as ErrorRecorder from './ErrorRecorder.js' +import * as RedisManager from './RedisManager.js' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as HashManager from './HashManager.js' + +const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs +const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days + +const keys = Settings.redis.lock.key_schema + +// db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true}) +// To add expiresAt field to existing entries in collection (choose a suitable future expiry date): +// db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}}) + +export function startResync(projectId, options, callback) { + // We have three options here + // + // 1. If we update mongo before making the call to web then there's a + // chance we ignore all updates indefinitely (there's no foolproff way + // to undo the change in mongo) + // + // 2. If we make the call to web first then there is a small race condition + // where we could process the sync update and then only update mongo + // after, causing all updates to be ignored from then on + // + // 3. We can wrap everything in a project lock + if (typeof options === 'function') { + callback = options + options = {} + } + + Metrics.inc('project_history_resync') + LockManager.runWithLock( + keys.projectHistoryLock({ project_id: projectId }), + (extendLock, releaseLock) => + _startResyncWithoutLock(projectId, options, releaseLock), + function (error) { + if (error) { + OError.tag(error) + // record error in starting sync ("sync ongoing") + ErrorRecorder.record(projectId, -1, error, () => callback(error)) + } else { + callback() + } + } + ) +} + +export function startHardResync(projectId, options, callback) { + if (typeof options === 'function') { + callback = options + options = {} + } + + Metrics.inc('project_history_hard_resync') + LockManager.runWithLock( + keys.projectHistoryLock({ project_id: projectId }), + (extendLock, releaseLock) => + clearResyncState(projectId, function (err) { + if (err) { + return releaseLock(OError.tag(err)) + } + RedisManager.destroyDocUpdatesQueue(projectId, function (err) { + if (err) { + return releaseLock(OError.tag(err)) + } + _startResyncWithoutLock(projectId, options, releaseLock) + }) + }), + function (error) { + if (error) { + OError.tag(error) + // record error in starting sync ("sync ongoing") + ErrorRecorder.record(projectId, -1, error, () => callback(error)) + } else { + callback() + } + } + ) +} + +function _startResyncWithoutLock(projectId, options, callback) { + ErrorRecorder.recordSyncStart(projectId, function (error) { + if (error) { + return callback(OError.tag(error)) + } + + _getResyncState(projectId, function (error, syncState) { + if (error) { + return callback(OError.tag(error)) + } + + if (syncState.isSyncOngoing()) { + return callback(new OError('sync ongoing')) + } + + syncState.setOrigin(options.origin || { kind: 'history-resync' }) + syncState.startProjectStructureSync() + + WebApiManager.requestResync(projectId, function (error) { + if (error) { + return callback(OError.tag(error)) + } + + setResyncState(projectId, syncState, callback) + }) + }) + }) +} + +function _getResyncState(projectId, callback) { + db.projectHistorySyncState.findOne( + { + project_id: ObjectId(projectId.toString()), + }, + function (error, rawSyncState) { + if (error) { + return callback(OError.tag(error)) + } + const syncState = SyncState.fromRaw(projectId, rawSyncState) + callback(null, syncState) + } + ) +} + +export function setResyncState(projectId, syncState, callback) { + if (syncState == null) { + return callback() + } // skip if syncState is null (i.e. unchanged) + const update = { + $set: syncState.toRaw(), + $push: { + history: { + $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }], + $position: 0, + $slice: MAX_RESYNC_HISTORY_RECORDS, + }, + }, + $currentDate: { lastUpdated: true }, + } + // handle different cases + if (syncState.isSyncOngoing()) { + // starting a new sync + update.$inc = { resyncCount: 1 } + update.$unset = { expiresAt: true } // prevent the entry expiring while sync is in ongoing + } else { + // successful completion of existing sync + update.$set.expiresAt = new Date( + Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS + ) // set the entry to expire in the future + } + // apply the update + db.projectHistorySyncState.updateOne( + { + project_id: ObjectId(projectId), + }, + update, + { + upsert: true, + }, + callback + ) +} + +export function clearResyncState(projectId, callback) { + db.projectHistorySyncState.deleteOne( + { + project_id: ObjectId(projectId.toString()), + }, + callback + ) +} + +export function skipUpdatesDuringSync(projectId, updates, callback) { + _getResyncState(projectId, function (error, syncState) { + if (error) { + return callback(OError.tag(error)) + } + + if (!syncState.isSyncOngoing()) { + logger.debug({ projectId }, 'not skipping updates: no resync in progress') + return callback(null, updates) // don't return synsState when unchanged + } + + const filteredUpdates = [] + + for (const update of updates) { + try { + syncState.updateState(update) + } catch (error1) { + error = OError.tag(error1) + if (error instanceof SyncError) { + return callback(error) + } else { + throw error + } + } + + const shouldSkipUpdate = syncState.shouldSkipUpdate(update) + if (!shouldSkipUpdate) { + filteredUpdates.push(update) + } else { + logger.debug({ projectId, update }, 'skipping update due to resync') + } + } + + callback(null, filteredUpdates, syncState) + }) +} + +export function expandSyncUpdates( + projectId, + projectHistoryId, + updates, + extendLock, + callback +) { + const areSyncUpdatesQueued = + _.some(updates, 'resyncProjectStructure') || + _.some(updates, 'resyncDocContent') + if (!areSyncUpdatesQueued) { + logger.debug({ projectId }, 'no resync updates to expand') + return callback(null, updates) + } + + _getResyncState(projectId, (error, syncState) => { + if (error) { + return callback(OError.tag(error)) + } + + // compute the current snapshot from the most recent chunk + SnapshotManager.getLatestSnapshot( + projectId, + projectHistoryId, + (error, snapshotFiles) => { + if (error) { + return callback(OError.tag(error)) + } + // check if snapshot files are valid + const invalidFiles = _.pickBy( + snapshotFiles, + (v, k) => v == null || typeof v.isEditable !== 'function' + ) + if (_.size(invalidFiles) > 0) { + return callback( + new SyncError('file is missing isEditable method', { + projectId, + invalidFiles, + }) + ) + } + const expander = new SyncUpdateExpander( + projectId, + snapshotFiles, + syncState.origin + ) + // expand updates asynchronously to avoid blocking + const handleUpdate = ( + update, + cb // n.b. lock manager calls cb asynchronously + ) => + expander.expandUpdate(update, error => { + if (error) { + return cb(OError.tag(error)) + } + extendLock(cb) + }) + async.eachSeries(updates, handleUpdate, error => { + if (error) { + return callback(OError.tag(error)) + } + callback(null, expander.getExpandedUpdates()) + }) + } + ) + }) +} + +class SyncState { + constructor(projectId, resyncProjectStructure, resyncDocContents, origin) { + this.projectId = projectId + this.resyncProjectStructure = resyncProjectStructure + this.resyncDocContents = resyncDocContents + this.origin = origin + } + + static fromRaw(projectId, rawSyncState) { + rawSyncState = rawSyncState || {} + const resyncProjectStructure = rawSyncState.resyncProjectStructure || false + const resyncDocContents = new Set(rawSyncState.resyncDocContents || []) + const origin = rawSyncState.origin + return new SyncState( + projectId, + resyncProjectStructure, + resyncDocContents, + origin + ) + } + + toRaw() { + return { + resyncProjectStructure: this.resyncProjectStructure, + resyncDocContents: Array.from(this.resyncDocContents), + origin: this.origin, + } + } + + updateState(update) { + if (update.resyncProjectStructure != null) { + if (!this.isProjectStructureSyncing()) { + throw new SyncError('unexpected resyncProjectStructure update', { + projectId: this.projectId, + resyncProjectStructure: this.resyncProjectStructure, + }) + } + if (this.isAnyDocContentSyncing()) { + throw new SyncError('unexpected resyncDocContents update', { + projectId: this.projectId, + resyncDocContents: this.resyncDocContents, + }) + } + + for (const doc of update.resyncProjectStructure.docs) { + this.startDocContentSync(doc.path) + } + + this.stopProjectStructureSync() + } else if (update.resyncDocContent != null) { + if (this.isProjectStructureSyncing()) { + throw new SyncError('unexpected resyncDocContent update', { + projectId: this.projectId, + resyncProjectStructure: this.resyncProjectStructure, + }) + } + + if (!this.isDocContentSyncing(update.path)) { + throw new SyncError('unexpected resyncDocContent update', { + projectId: this.projectId, + resyncDocContents: this.resyncDocContents, + path: update.path, + }) + } + + this.stopDocContentSync(update.path) + } + } + + setOrigin(origin) { + this.origin = origin + } + + shouldSkipUpdate(update) { + // don't skip sync updates + if ( + update.resyncProjectStructure != null || + update.resyncDocContent != null + ) { + return false + } + + // if syncing project structure skip update + if (this.isProjectStructureSyncing()) { + return true + } + + // skip text updates for a docs being synced + if (UpdateTranslator.isTextUpdate(update)) { + if (this.isDocContentSyncing(update.meta.pathname)) { + return true + } + } + + // preserve all other updates + return false + } + + startProjectStructureSync() { + this.resyncProjectStructure = true + this.resyncDocContents = new Set([]) + } + + stopProjectStructureSync() { + this.resyncProjectStructure = false + } + + stopDocContentSync(pathname) { + this.resyncDocContents.delete(pathname) + } + + startDocContentSync(pathname) { + this.resyncDocContents.add(pathname) + } + + isProjectStructureSyncing() { + return this.resyncProjectStructure + } + + isDocContentSyncing(pathname) { + return this.resyncDocContents.has(pathname) + } + + isAnyDocContentSyncing() { + return this.resyncDocContents.size > 0 + } + + isSyncOngoing() { + return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing() + } +} + +class SyncUpdateExpander { + constructor(projectId, snapshotFiles, origin) { + this.projectId = projectId + this.files = snapshotFiles + this.expandedUpdates = [] + this.origin = origin + } + + // If there's an expected *file* with the same path and either the same hash + // or no hash, treat this as not editable even if history thinks it is. + isEditable(filePath, file, expectedFiles) { + if (!file.isEditable()) { + return false + } + const fileHash = _.get(file, ['data', 'hash']) + const matchedExpectedFile = expectedFiles.some(item => { + const expectedFileHash = item._hash + if (expectedFileHash && fileHash !== expectedFileHash) { + // expected file has a hash and it doesn't match + return false + } + return UpdateTranslator._convertPathname(item.path) === filePath + }) + + // consider editable file in history as binary, since it matches a binary file in the project + return !matchedExpectedFile + } + + expandUpdate(update, cb) { + if (update.resyncProjectStructure != null) { + logger.debug( + { projectId: this.projectId, update }, + 'expanding resyncProjectStructure update' + ) + const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) => + this.isEditable(k, v, update.resyncProjectStructure.files) + ) + const persistedNonBinaryFiles = _.map( + Object.keys(persistedNonBinaryFileEntries), + path => ({ + path, + }) + ) + + const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) => + this.isEditable(k, v, update.resyncProjectStructure.files) + ) + // preserve file properties on binary files, for future comparison. + const persistedBinaryFiles = _.map( + persistedBinaryFileEntries, + (entity, key) => Object.assign({}, entity, { path: key }) + ) + const expectedNonBinaryFiles = _.map( + update.resyncProjectStructure.docs, + entity => + Object.assign({}, entity, { + path: UpdateTranslator._convertPathname(entity.path), + }) + ) + const expectedBinaryFiles = _.map( + update.resyncProjectStructure.files, + entity => + Object.assign({}, entity, { + path: UpdateTranslator._convertPathname(entity.path), + }) + ) + + // We need to detect and fix consistency issues where web and + // history-store disagree on whether an entity is binary or not. Thus we + // need to remove and add the two separately. + this.queueRemoveOpsForUnexpectedFiles( + update, + expectedBinaryFiles, + persistedBinaryFiles + ) + this.queueRemoveOpsForUnexpectedFiles( + update, + expectedNonBinaryFiles, + persistedNonBinaryFiles + ) + this.queueAddOpsForMissingFiles( + update, + expectedBinaryFiles, + persistedBinaryFiles + ) + this.queueAddOpsForMissingFiles( + update, + expectedNonBinaryFiles, + persistedNonBinaryFiles + ) + this.queueUpdateForOutOfSyncBinaryFiles( + update, + expectedBinaryFiles, + persistedBinaryFiles + ) + cb() + } else if (update.resyncDocContent != null) { + logger.debug( + { projectId: this.projectId, update }, + 'expanding resyncDocContent update' + ) + this.queueTextOpForOutOfSyncContents(update, cb) + } else { + this.expandedUpdates.push(update) + cb() + } + } + + getExpandedUpdates() { + return this.expandedUpdates + } + + queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) { + const unexpectedFiles = _.differenceBy( + persistedFiles, + expectedFiles, + 'path' + ) + for (const entity of unexpectedFiles) { + update = { + pathname: entity.path, + new_pathname: '', + meta: { + resync: true, + origin: this.origin, + ts: update.meta.ts, + }, + } + this.expandedUpdates.push(update) + Metrics.inc('project_history_resync_operation', 1, { + status: 'remove unexpected file', + }) + } + } + + queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) { + const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path') + for (const entity of missingFiles) { + update = { + pathname: entity.path, + meta: { + resync: true, + origin: this.origin, + ts: update.meta.ts, + }, + } + + if (entity.doc != null) { + update.doc = entity.doc + update.docLines = '' + // we have to create a dummy entry here because later we will need the content in the diff computation + this.files[update.pathname] = File.fromString('') + } else { + update.file = entity.file + update.url = entity.url + } + + this.expandedUpdates.push(update) + Metrics.inc('project_history_resync_operation', 1, { + status: 'add missing file', + }) + } + } + + queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) { + // create a map to lookup persisted files by their path + const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x])) + // now search for files with same path but different hash values + const differentFiles = expectedFiles.filter(expected => { + // check for a persisted file at the same path + const expectedPath = expected.path + const persistedFileAtSamePath = persistedFileMap.get(expectedPath) + if (!persistedFileAtSamePath) return false + // check if the persisted file at the same path has a different hash + const expectedHash = _.get(expected, '_hash') + const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash']) + const hashesPresent = expectedHash && persistedHash + return hashesPresent && persistedHash !== expectedHash + }) + for (const entity of differentFiles) { + // remove the outdated persisted file + const removeUpdate = { + pathname: entity.path, + new_pathname: '', + meta: { + resync: true, + origin: this.origin, + ts: update.meta.ts, + }, + } + this.expandedUpdates.push(removeUpdate) + // add the new file content + const addUpdate = { + pathname: entity.path, + meta: { + resync: true, + origin: this.origin, + ts: update.meta.ts, + }, + file: entity.file, + url: entity.url, + } + this.expandedUpdates.push(addUpdate) + Metrics.inc('project_history_resync_operation', 1, { + status: 'update binary file contents', + }) + } + } + + queueTextOpForOutOfSyncContents(update, cb) { + const pathname = UpdateTranslator._convertPathname(update.path) + const snapshotFile = this.files[pathname] + const expectedFile = update.resyncDocContent + + if (!snapshotFile) { + return cb(new OError('unrecognised file: not in snapshot')) + } + + // Compare hashes to see if the persisted file matches the expected content. + // The hash of the persisted files is stored in the snapshot. + // Note getHash() returns the hash only when the persisted file has + // no changes in the snapshot, the hash is null if there are changes + // that apply to it. + const persistedHash = + typeof snapshotFile.getHash === 'function' + ? snapshotFile.getHash() + : undefined + if (persistedHash != null) { + const expectedHash = HashManager._getBlobHashFromString( + expectedFile.content + ) + if (persistedHash === expectedHash) { + logger.debug( + { projectId: this.projectId, persistedHash, expectedHash }, + 'skipping diff because hashes match and persisted file has no ops' + ) + return cb() + } + } else { + logger.debug('cannot compare hashes, will retrieve content') + } + + const expectedContent = update.resyncDocContent.content + + const computeDiff = (persistedContent, cb) => { + let op + logger.debug( + { projectId: this.projectId, persistedContent, expectedContent }, + 'diffing doc contents' + ) + try { + op = UpdateCompressor.diffAsShareJsOps( + persistedContent, + expectedContent + ) + } catch (error) { + return cb( + OError.tag(error, 'error from diffAsShareJsOps', { + projectId: this.projectId, + persistedContent, + expectedContent, + }) + ) + } + if (op.length === 0) { + return cb() + } + update = { + doc: update.doc, + op, + meta: { + resync: true, + origin: this.origin, + ts: update.meta.ts, + pathname, + doc_length: persistedContent.length, + }, + } + logger.debug( + { projectId: this.projectId, diffCount: op.length }, + 'doc contents differ' + ) + this.expandedUpdates.push(update) + Metrics.inc('project_history_resync_operation', 1, { + status: 'update text file contents', + }) + cb() + } + + // compute the difference between the expected and persisted content + if (snapshotFile.load != null) { + WebApiManager.getHistoryId(this.projectId, (err, historyId) => { + if (err) { + return cb(OError.tag(err)) + } + const loadFile = snapshotFile.load( + 'eager', + HistoryStoreManager.getBlobStore(historyId) + ) + loadFile + .then(file => computeDiff(file.getContent(), cb)) + .catch(err => cb(err)) // error loading file or computing diff + }) + } else if (snapshotFile.content != null) { + // use dummy content from queueAddOpsForMissingFiles for added missing files + computeDiff(snapshotFile.content, cb) + } else { + cb(new OError('unrecognised file')) + } + } +} + +export const promises = { + startResync: promisify(startResync), + startHardResync: promisify(startHardResync), + setResyncState: promisify(setResyncState), + clearResyncState: promisify(clearResyncState), + skipUpdatesDuringSync: promisify(skipUpdatesDuringSync), + expandSyncUpdates: promisify(expandSyncUpdates), +} diff --git a/services/project-history/app/js/UpdateCompressor.js b/services/project-history/app/js/UpdateCompressor.js new file mode 100644 index 0000000000..16f111e7ab --- /dev/null +++ b/services/project-history/app/js/UpdateCompressor.js @@ -0,0 +1,302 @@ +import OError from '@overleaf/o-error' +import DMP from 'diff-match-patch' + +const MAX_TIME_BETWEEN_UPDATES = 60 * 1000 // one minute +const MAX_UPDATE_SIZE = 2 * 1024 * 1024 // 2 MB +const ADDED = 1 +const REMOVED = -1 +const UNCHANGED = 0 + +const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos) +const strRemove = (s1, pos, length) => s1.slice(0, pos) + s1.slice(pos + length) + +const dmp = new DMP() +dmp.Diff_Timeout = 0.1 // prevent the diff algorithm from searching too hard for changes in unrelated content + +const cloneWithOp = function (update, op) { + // to improve performance, shallow clone the update + // and its meta property (also an object), then + // overwrite the op property directly. + update = Object.assign({}, update) + update.meta = Object.assign({}, update.meta) + update.op = op + return update +} +const mergeUpdatesWithOp = function (firstUpdate, secondUpdate, op) { + // We want to take doc_length and ts from the firstUpdate, v from the second + const update = cloneWithOp(firstUpdate, op) + if (secondUpdate.v != null) { + update.v = secondUpdate.v + } + return update +} + +const adjustLengthByOp = function (length, op) { + if (op.i != null) { + return length + op.i.length + } else if (op.d != null) { + return length - op.d.length + } else { + throw new OError('unexpected op type') + } +} + +// Updates come from the doc updater in format +// { +// op: [ { ... op1 ... }, { ... op2 ... } ] +// meta: { ts: ..., user_id: ... } +// } +// but it's easier to work with on op per update, so convert these updates to +// our compressed format +// [{ +// op: op1 +// meta: { ts: ..., user_id: ... } +// }, { +// op: op2 +// meta: { ts: ..., user_id: ... } +// }] +export function convertToSingleOpUpdates(updates) { + const splitUpdates = [] + for (const update of updates) { + if (update.op == null) { + // Not a text op, likely a project strucure op + splitUpdates.push(update) + continue + } + // Reject any non-insert or delete ops, i.e. comments + const ops = update.op.filter(o => o.i != null || o.d != null) + let { doc_length: docLength } = update.meta + for (const op of ops) { + const splitUpdate = cloneWithOp(update, op) + if (docLength != null) { + splitUpdate.meta.doc_length = docLength + docLength = adjustLengthByOp(docLength, op) + } + splitUpdates.push(splitUpdate) + } + } + return splitUpdates +} + +export function filterBlankUpdates(updates) { + // Diffing an insert and delete can return blank inserts and deletes + // which the OL history service doesn't have an equivalent for. + // + // NOTE: this relies on the updates only containing either op.i or op.d entries + // but not both, which is the case because diffAsShareJsOps does this + return updates.filter( + update => !(update.op && (update.op.i === '' || update.op.d === '')) + ) +} + +export function concatUpdatesWithSameVersion(updates) { + const concattedUpdates = [] + for (let update of updates) { + if (update.op != null) { + update = cloneWithOp(update, [update.op]) + + const lastUpdate = concattedUpdates[concattedUpdates.length - 1] + if ( + lastUpdate != null && + lastUpdate.op != null && + lastUpdate.v === update.v && + lastUpdate.doc === update.doc && + lastUpdate.pathname === update.pathname + ) { + lastUpdate.op = lastUpdate.op.concat(update.op) + } else { + concattedUpdates.push(update) + } + } else { + concattedUpdates.push(update) + } + } + return concattedUpdates +} + +export function compressRawUpdates(rawUpdates) { + let updates = convertToSingleOpUpdates(rawUpdates) + updates = compressUpdates(updates) + updates = filterBlankUpdates(updates) + updates = concatUpdatesWithSameVersion(updates) + return updates +} + +export function compressUpdates(updates) { + if (updates.length === 0) { + return [] + } + + let compressedUpdates = [updates.shift()] + for (const update of updates) { + const lastCompressedUpdate = compressedUpdates.pop() + if (lastCompressedUpdate != null) { + const newCompressedUpdates = _concatTwoUpdates( + lastCompressedUpdate, + update + ) + + compressedUpdates = compressedUpdates.concat(newCompressedUpdates) + } else { + compressedUpdates.push(update) + } + } + + return compressedUpdates +} + +function _concatTwoUpdates(firstUpdate, secondUpdate) { + // Previously we cloned firstUpdate and secondUpdate at this point but we + // can skip this step because whenever they are returned with + // modification there is always a clone at that point via + // mergeUpdatesWithOp. + + let offset + if (firstUpdate.op == null || secondUpdate.op == null) { + // Project structure ops + return [firstUpdate, secondUpdate] + } + + if ( + firstUpdate.doc !== secondUpdate.doc || + firstUpdate.pathname !== secondUpdate.pathname + ) { + return [firstUpdate, secondUpdate] + } + + if (firstUpdate.meta.user_id !== secondUpdate.meta.user_id) { + return [firstUpdate, secondUpdate] + } + + if ( + (firstUpdate.meta.type === 'external' && + secondUpdate.meta.type !== 'external') || + (firstUpdate.meta.type !== 'external' && + secondUpdate.meta.type === 'external') || + (firstUpdate.meta.type === 'external' && + secondUpdate.meta.type === 'external' && + firstUpdate.meta.source !== secondUpdate.meta.source) + ) { + return [firstUpdate, secondUpdate] + } + + if (secondUpdate.meta.ts - firstUpdate.meta.ts > MAX_TIME_BETWEEN_UPDATES) { + return [firstUpdate, secondUpdate] + } + + const firstOp = firstUpdate.op + const secondOp = secondUpdate.op + const firstSize = + (firstOp.i && firstOp.i.length) || (firstOp.d && firstOp.d.length) + const secondSize = + (secondOp.i && secondOp.i.length) || (secondOp.d && secondOp.d.length) + const firstOpInsideSecondOp = + secondOp.p <= firstOp.p && firstOp.p <= secondOp.p + secondSize + const secondOpInsideFirstOp = + firstOp.p <= secondOp.p && secondOp.p <= firstOp.p + firstSize + const combinedLengthUnderLimit = firstSize + secondSize < MAX_UPDATE_SIZE + + // Two inserts + if ( + firstOp.i != null && + secondOp.i != null && + secondOpInsideFirstOp && + combinedLengthUnderLimit + ) { + return [ + mergeUpdatesWithOp(firstUpdate, secondUpdate, { + p: firstOp.p, + i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i), + }), + ] + // Two deletes + } else if ( + firstOp.d != null && + secondOp.d != null && + firstOpInsideSecondOp && + combinedLengthUnderLimit + ) { + return [ + mergeUpdatesWithOp(firstUpdate, secondUpdate, { + p: secondOp.p, + d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d), + }), + ] + // An insert and then a delete + } else if (firstOp.i != null && secondOp.d != null && secondOpInsideFirstOp) { + offset = secondOp.p - firstOp.p + const insertedText = firstOp.i.slice(offset, offset + secondOp.d.length) + // Only trim the insert when the delete is fully contained within in it + if (insertedText === secondOp.d) { + const insert = strRemove(firstOp.i, offset, secondOp.d.length) + if (insert === '') { + return [] + } else { + return [ + mergeUpdatesWithOp(firstUpdate, secondUpdate, { + p: firstOp.p, + i: insert, + }), + ] + } + } else { + // This will only happen if the delete extends outside the insert + return [firstUpdate, secondUpdate] + } + + // A delete then an insert at the same place, likely a copy-paste of a chunk of content + } else if ( + firstOp.d != null && + secondOp.i != null && + firstOp.p === secondOp.p + ) { + offset = firstOp.p + const diffUpdates = diffAsShareJsOps(firstOp.d, secondOp.i).map(function ( + op + ) { + op.p += offset + return mergeUpdatesWithOp(firstUpdate, secondUpdate, op) + }) + + // Doing a diff like this loses track of the doc lengths for each + // update, so recalculate them + let { doc_length: docLength } = firstUpdate.meta + for (const update of diffUpdates) { + update.meta.doc_length = docLength + docLength = adjustLengthByOp(docLength, update.op) + } + + return diffUpdates + } else { + return [firstUpdate, secondUpdate] + } +} + +export function diffAsShareJsOps(before, after) { + const diffs = dmp.diff_main(before, after) + dmp.diff_cleanupSemantic(diffs) + + const ops = [] + let position = 0 + for (const diff of diffs) { + const type = diff[0] + const content = diff[1] + if (type === ADDED) { + ops.push({ + i: content, + p: position, + }) + position += content.length + } else if (type === REMOVED) { + ops.push({ + d: content, + p: position, + }) + } else if (type === UNCHANGED) { + position += content.length + } else { + throw new Error('Unknown type') + } + } + return ops +} diff --git a/services/project-history/app/js/UpdateTranslator.js b/services/project-history/app/js/UpdateTranslator.js new file mode 100644 index 0000000000..f0ab6d943d --- /dev/null +++ b/services/project-history/app/js/UpdateTranslator.js @@ -0,0 +1,201 @@ +import _ from 'lodash' +import Core from 'overleaf-editor-core' +import * as Errors from './Errors.js' +import * as OperationsCompressor from './OperationsCompressor.js' + +export function convertToChanges(projectId, updatesWithBlobs, callback) { + let changes + try { + // convert update to change + changes = updatesWithBlobs.map(update => + _convertToChange(projectId, update) + ) + } catch (error1) { + const error = error1 + if ( + error instanceof Errors.UpdateWithUnknownFormatError || + error instanceof Errors.UnexpectedOpTypeError + ) { + return callback(error) + } else { + throw error + } + } + + callback(null, changes) +} + +function _convertToChange(projectId, updateWithBlob) { + let operations + const { update } = updateWithBlob + + let projectVersion = null + const v2DocVersions = {} + + if (_isRenameUpdate(update)) { + operations = [ + { + pathname: _convertPathname(update.pathname), + newPathname: _convertPathname(update.new_pathname), + }, + ] + projectVersion = update.version + } else if (isAddUpdate(update)) { + operations = [ + { + pathname: _convertPathname(update.pathname), + file: { + hash: updateWithBlob.blobHash, + }, + }, + ] + projectVersion = update.version + } else if (isTextUpdate(update)) { + const docLength = update.meta.doc_length + let pathname = update.meta.pathname + + pathname = _convertPathname(pathname) + const builder = new TextOperationsBuilder(update.doc, docLength, pathname) + // convert ops + for (const op of update.op) { + builder.addOp(op) + } // if this throws an exception it will be caught in convertToChanges + operations = builder.finish() + // add doc version information if present + if (update.v != null) { + v2DocVersions[update.doc] = { pathname, v: update.v } + } + } else { + const error = new Errors.UpdateWithUnknownFormatError( + 'update with unknown format', + { projectId, update } + ) + throw error + } + + const rawChange = { + operations, + v2Authors: _.compact([update.meta.user_id]), + timestamp: new Date(update.meta.ts).toISOString(), + projectVersion, + v2DocVersions: Object.keys(v2DocVersions).length ? v2DocVersions : null, + } + if (update.meta.origin) { + rawChange.origin = update.meta.origin + } else if (update.meta.type === 'external' && update.meta.source) { + rawChange.origin = { kind: update.meta.source } + } + const change = Core.Change.fromRaw(rawChange) + + change.operations = OperationsCompressor.compressOperations(change.operations) + + return change +} + +function _isRenameUpdate(update) { + return update.new_pathname != null +} + +function _isAddDocUpdate(update) { + return update.doc != null && update.docLines != null +} + +function _isAddFileUpdate(update) { + return update.file != null && update.url != null +} + +export function isTextUpdate(update) { + return ( + update.doc != null && + update.op != null && + update.meta.pathname != null && + update.meta.doc_length != null + ) +} + +export function isProjectStructureUpdate(update) { + return isAddUpdate(update) || _isRenameUpdate(update) +} + +export function isAddUpdate(update) { + return _isAddDocUpdate(update) || _isAddFileUpdate(update) +} + +export function _convertPathname(pathname) { + // Strip leading / + pathname = pathname.replace(/^\//, '') + // Replace \\ with _. Backslashes are no longer allowed + // in projects in web, but we have some which have gone through + // into history before this restriction was added. This makes + // them valid for the history store. + // See https://github.com/overleaf/write_latex/issues/4471 + pathname = pathname.replace(/\\/g, '_') + // workaround for filenames containing asterisks, this will + // fail if a corresponding replacement file already exists but it + // would fail anyway without this attempt to fix the pathname. + // See https://github.com/overleaf/sharelatex/issues/900 + pathname = pathname.replace(/\*/g, '__ASTERISK__') + // workaround for filenames beginning with spaces + // See https://github.com/overleaf/sharelatex/issues/1404 + // note: we have already stripped any leading slash above + pathname = pathname.replace(/^ /, '__SPACE__') // handle top-level + pathname = pathname.replace(/\/ /g, '/__SPACE__') // handle folders + return pathname +} + +class TextOperationsBuilder { + constructor(docId, docLength, pathname) { + this.operations = [] + this.doc_id = docId + this.doc_length = docLength + this.pathname = pathname + } + + addOp(op) { + let retain + if (op.c != null) { + return // ignore comment op + } + if (op.i == null && op.d == null) { + throw new Errors.UnexpectedOpTypeError('unexpected op type', { op }) + } + + // We sometimes receive operations that operate at positions outside the + // doc_length. Document updater coerces the position to the end of the + // document. We do the same here. + const pos = Math.min(op.p, this.doc_length) + + const textOperation = [] + if (pos > 0) { + textOperation.push(pos) + } + + if (op.i != null) { + textOperation.push(op.i) + retain = this.doc_length - pos + this.doc_length += op.i.length + } + + if (op.d != null) { + textOperation.push(-op.d.length) + retain = this.doc_length - pos - op.d.length + this.doc_length -= op.d.length + } + + if (retain > 0) { + textOperation.push(retain) + } + this.pushTextOperation(textOperation) + } + + pushTextOperation(textOperation) { + this.operations.push({ + pathname: this.pathname, + textOperation, + }) + } + + finish() { + return this.operations + } +} diff --git a/services/project-history/app/js/UpdatesProcessor.js b/services/project-history/app/js/UpdatesProcessor.js new file mode 100644 index 0000000000..f797a5c5dc --- /dev/null +++ b/services/project-history/app/js/UpdatesProcessor.js @@ -0,0 +1,629 @@ +import { promisify } from 'util' +import logger from '@overleaf/logger' +import async from 'async' +import metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' +import OError from '@overleaf/o-error' +import * as HistoryStoreManager from './HistoryStoreManager.js' +import * as UpdateTranslator from './UpdateTranslator.js' +import * as BlobManager from './BlobManager.js' +import * as RedisManager from './RedisManager.js' +import * as ErrorRecorder from './ErrorRecorder.js' +import * as LockManager from './LockManager.js' +import * as UpdateCompressor from './UpdateCompressor.js' +import * as WebApiManager from './WebApiManager.js' +import * as SyncManager from './SyncManager.js' +import * as Versions from './Versions.js' +import * as Errors from './Errors.js' +import { Profiler } from './Profiler.js' + +const keys = Settings.redis.lock.key_schema + +const PROJECT_HISTORY = { + ENABLED: 'enabled', + NOT_ENABLED: 'not-enabled', +} + +export const REDIS_READ_BATCH_SIZE = 500 + +/** + * Container for functions that need to be mocked in tests + * + * TODO: Rewrite tests in terms of exported functions only + */ +export const _mocks = {} + +export function getRawUpdates(projectId, batchSize, callback) { + RedisManager.getOldestDocUpdates( + projectId, + batchSize, + (error, rawUpdates) => { + if (error != null) { + return callback(OError.tag(error)) + } + RedisManager.parseDocUpdates(rawUpdates, (error, updates) => { + if (error != null) { + return callback(OError.tag(error)) + } + _getHistoryId(projectId, updates, (error, historyId) => { + if (error != null) { + return callback(OError.tag(error)) + } + HistoryStoreManager.getMostRecentChunk( + projectId, + historyId, + (error, chunk) => { + if (error != null) { + return callback(OError.tag(error)) + } + callback(null, { project_id: projectId, chunk, updates }) + } + ) + }) + }) + } + ) +} + +// Process all updates for a project, only check project-level information once +export function processUpdatesForProject(projectId, callback) { + LockManager.runWithLock( + keys.projectHistoryLock({ project_id: projectId }), + (extendLock, releaseLock) => { + _countAndProcessUpdates( + projectId, + extendLock, + REDIS_READ_BATCH_SIZE, + releaseLock + ) + }, + (error, queueSize) => { + if (error) { + OError.tag(error) + } + ErrorRecorder.record(projectId, queueSize, error, callback) + if (error == null) { + // clear the flush marker in the background if the queue was fully cleared + RedisManager.clearFirstOpTimestamp(projectId) + } + } + ) +} + +export function processUpdatesForProjectUsingBisect( + projectId, + amountToProcess, + callback +) { + LockManager.runWithLock( + keys.projectHistoryLock({ project_id: projectId }), + (extendLock, releaseLock) => { + _countAndProcessUpdates( + projectId, + extendLock, + amountToProcess, + releaseLock + ) + }, + (error, queueSize) => { + if (amountToProcess === 0 || queueSize === 0) { + // no further processing possible + if (error != null) { + ErrorRecorder.record( + projectId, + queueSize, + OError.tag(error), + callback + ) + } else { + callback() + } + } else { + if (error != null) { + // decrease the batch size when we hit an error + processUpdatesForProjectUsingBisect( + projectId, + Math.floor(amountToProcess / 2), + callback + ) + } else { + // otherwise continue processing with the same batch size + processUpdatesForProjectUsingBisect( + projectId, + amountToProcess, + callback + ) + } + } + } + ) +} + +export function processSingleUpdateForProject(projectId, callback) { + LockManager.runWithLock( + keys.projectHistoryLock({ project_id: projectId }), + ( + extendLock, + releaseLock // set the batch size to 1 for single-stepping + ) => { + _countAndProcessUpdates(projectId, extendLock, 1, releaseLock) + }, + ( + error, + queueSize // no need to clear the flush marker when single stepping + ) => { + // it will be cleared up on the next background flush if + // the queue is empty + ErrorRecorder.record(projectId, queueSize, error, callback) + } + ) +} + +_mocks._countAndProcessUpdates = ( + projectId, + extendLock, + batchSize, + callback +) => { + RedisManager.countUnprocessedUpdates(projectId, (error, queueSize) => { + if (error != null) { + return callback(OError.tag(error)) + } + if (queueSize > 0) { + logger.debug({ projectId, queueSize }, 'processing uncompressed updates') + RedisManager.getUpdatesInBatches( + projectId, + batchSize, + (updates, cb) => { + _processUpdatesBatch(projectId, updates, extendLock, cb) + }, + (error, isProjectHistoryEnabled) => { + // We can error before it is known whether project history is enabled + // for the project, so this key has 3 values. + const enabled = isProjectHistoryEnabled || 'unknown' + // This metrics key tries to convet that processing is not atomic. + // Some updates may have been processed even if there was an error. + const success = error != null ? 'with-error' : 'without-error' + metrics.gauge(`updates.${enabled}.${success}`, queueSize) + metrics.count(`updates.${enabled}.${success}`, queueSize) + callback(error, queueSize) + } + ) + } else { + logger.debug({ projectId }, 'no updates to process') + callback(null, queueSize) + } + }) +} + +function _countAndProcessUpdates(...args) { + _mocks._countAndProcessUpdates(...args) +} + +function _processUpdatesBatch(projectId, updates, extendLock, callback) { + // If the project doesn't have a history then we can bail out here + _getHistoryId(projectId, updates, (error, historyId) => { + if (error != null) { + return callback(OError.tag(error)) + } + + if (historyId == null) { + logger.debug( + { projectId }, + 'discarding updates as project does not use history' + ) + return callback(null, PROJECT_HISTORY.NOT_ENABLED) + } + + _processUpdates(projectId, historyId, updates, extendLock, error => { + if (error != null) { + return callback(OError.tag(error), PROJECT_HISTORY.ENABLED) + } + callback(null, PROJECT_HISTORY.ENABLED) + }) + }) +} + +export function _getHistoryId(projectId, updates, callback) { + let idFromUpdates = null + + // check that all updates have the same history id + for (const update of updates) { + if (update.projectHistoryId != null) { + if (idFromUpdates == null) { + idFromUpdates = update.projectHistoryId.toString() + } else if (idFromUpdates !== update.projectHistoryId.toString()) { + metrics.inc('updates.batches.project-history-id.inconsistent-update') + logger.warn( + { + projectId, + updates, + idFromUpdates, + currentId: update.projectHistoryId, + }, + 'inconsistent project history id between updates' + ) + return callback( + new OError('inconsistent project history id between updates') + ) + } + } + } + + WebApiManager.getHistoryId(projectId, (error, idFromWeb, cached) => { + if (error != null && idFromUpdates != null) { + // present only on updates + // 404s from web are an error + metrics.inc('updates.batches.project-history-id.from-updates') + return callback(null, idFromUpdates) + } else if (error != null) { + return callback(OError.tag(error)) + } + + if (idFromWeb == null && idFromUpdates == null) { + // present on neither web nor updates + callback(null, null) + } else if (idFromWeb != null && idFromUpdates == null) { + // present only on web + metrics.inc('updates.batches.project-history-id.from-web') + callback(null, idFromWeb) + } else if (idFromWeb == null && idFromUpdates != null) { + // present only on updates + metrics.inc('updates.batches.project-history-id.from-updates') + callback(null, idFromUpdates) + } else if (idFromWeb.toString() !== idFromUpdates.toString()) { + // inconsistent between web and updates + metrics.inc('updates.batches.project-history-id.inconsistent-with-web') + logger.warn( + { + projectId, + idFromWeb, + idFromUpdates, + idWasCached: cached, + updates, + }, + 'inconsistent project history id between updates and web' + ) + callback( + new OError('inconsistent project history id between updates and web') + ) + } else { + // the same on web and updates + metrics.inc('updates.batches.project-history-id.from-updates') + callback(null, idFromWeb) + } + }) +} + +function _handleOpsOutOfOrderError(projectId, projectHistoryId, err, ...rest) { + const adjustedLength = Math.max(rest.length, 1) + const results = rest.slice(0, adjustedLength - 1) + const callback = rest[adjustedLength - 1] + ErrorRecorder.getFailureRecord(projectId, (error, failureRecord) => { + if (error != null) { + return callback(error) + } + // Bypass ops-out-of-order errors in the stored chunk when in forceDebug mode + if (failureRecord != null && failureRecord.forceDebug === true) { + logger.warn( + { projectId, projectHistoryId }, + 'ops out of order in chunk, forced continue' + ) + callback(null, ...results) // return results without error + } else { + logger.warn( + { projectId, projectHistoryId }, + 'ops out of order in chunk, returning error' + ) + callback(err, ...results) + } + }) +} + +function _getMostRecentVersionWithDebug(projectId, projectHistoryId, callback) { + HistoryStoreManager.getMostRecentVersion( + projectId, + projectHistoryId, + (err, ...results) => { + if (err instanceof Errors.OpsOutOfOrderError) { + _handleOpsOutOfOrderError( + projectId, + projectHistoryId, + err, + ...results, + callback + ) + } else { + callback(err, ...results) + } + } + ) +} + +function _processUpdates( + projectId, + projectHistoryId, + updates, + extendLock, + callback +) { + const profile = new Profiler('_processUpdates', { + project_id: projectId, + projectHistoryId, + }) + // skip updates first if we're in a sync, we might not need to do anything else + SyncManager.skipUpdatesDuringSync( + projectId, + updates, + (error, filteredUpdates, newSyncState) => { + profile.log('skipUpdatesDuringSync') + if (error != null) { + return callback(error) + } + if (filteredUpdates.length === 0) { + // return early if there are no updates to apply + return SyncManager.setResyncState(projectId, newSyncState, callback) + } + // only make request to history service if we have actual updates to process + _getMostRecentVersionWithDebug( + projectId, + projectHistoryId, + (error, baseVersion, projectStructureAndDocVersions) => { + if (projectStructureAndDocVersions == null) { + projectStructureAndDocVersions = { project: null, docs: {} } + } + profile.log('getMostRecentVersion') + if (error != null) { + return callback(error) + } + async.waterfall( + [ + cb => { + cb = profile.wrap('expandSyncUpdates', cb) + SyncManager.expandSyncUpdates( + projectId, + projectHistoryId, + filteredUpdates, + extendLock, + cb + ) + }, + (expandedUpdates, cb) => { + let unappliedUpdates + try { + unappliedUpdates = _skipAlreadyAppliedUpdates( + projectId, + expandedUpdates, + projectStructureAndDocVersions + ) + } catch (err) { + return cb(err) + } + profile.log('skipAlreadyAppliedUpdates') + const compressedUpdates = + UpdateCompressor.compressRawUpdates(unappliedUpdates) + const timeTaken = profile + .log('compressRawUpdates') + .getTimeDelta() + if (timeTaken >= 1000) { + logger.debug( + { projectId, updates: unappliedUpdates, timeTaken }, + 'slow compression of raw updates' + ) + } + cb = profile.wrap('createBlobs', cb) + BlobManager.createBlobsForUpdates( + projectId, + projectHistoryId, + compressedUpdates, + extendLock, + cb + ) + }, + (updatesWithBlobs, cb) => { + cb = profile.wrap('convertToChanges', cb) + UpdateTranslator.convertToChanges( + projectId, + updatesWithBlobs, + cb + ) + }, + (changes, cb) => { + changes = changes.map(change => change.toRaw()) + let change + const numChanges = changes.length + const byteLength = Buffer.byteLength( + JSON.stringify(changes), + 'utf8' + ) + let numOperations = 0 + for (change of changes) { + if (change.operations != null) { + numOperations += change.operations.length + } + } + + metrics.timing('history-store.request.changes', numChanges, 1) + metrics.timing('history-store.request.bytes', byteLength, 1) + metrics.timing( + 'history-store.request.operations', + numOperations, + 1 + ) + + // thresholds taken from write_latex/main/lib/history_exporter.rb + if (numChanges > 1000) { + metrics.inc('history-store.request.exceeds-threshold.changes') + } + if (byteLength > Math.pow(1024, 2)) { + metrics.inc('history-store.request.exceeds-threshold.bytes') + const changeLengths = changes.map(change => + Buffer.byteLength(JSON.stringify(change), 'utf8') + ) + logger.warn( + { projectId, byteLength, changeLengths }, + 'change size exceeds limit' + ) + } + + cb = profile.wrap('sendChanges', cb) + // this is usually the longest request, so extend the lock before starting it + extendLock(error => { + if (error != null) { + return cb(error) + } + if (changes.length === 0) { + return cb() + } // avoid unnecessary requests to history service + HistoryStoreManager.sendChanges( + projectId, + projectHistoryId, + changes, + baseVersion, + cb + ) + }) + }, + cb => { + cb = profile.wrap('setResyncState', cb) + SyncManager.setResyncState(projectId, newSyncState, cb) + }, + ], + error => { + profile.end() + callback(error) + } + ) + } + ) + } + ) +} + +_mocks._skipAlreadyAppliedUpdates = ( + projectId, + updates, + projectStructureAndDocVersions +) => { + function alreadySeenProjectVersion(previousProjectStructureVersion, update) { + return ( + UpdateTranslator.isProjectStructureUpdate(update) && + previousProjectStructureVersion != null && + update.version != null && + Versions.gte(previousProjectStructureVersion, update.version) + ) + } + + function alreadySeenDocVersion(previousDocVersions, update) { + if (UpdateTranslator.isTextUpdate(update) && update.v != null) { + const docId = update.doc + return ( + previousDocVersions[docId] != null && + previousDocVersions[docId].v != null && + Versions.gte(previousDocVersions[docId].v, update.v) + ) + } else { + return false + } + } + + // check that the incoming updates are in the correct order (we do not + // want to send out of order updates to the history service) + let incomingProjectStructureVersion = null + const incomingDocVersions = {} + for (const update of updates) { + if (alreadySeenProjectVersion(incomingProjectStructureVersion, update)) { + logger.warn( + { projectId, update, incomingProjectStructureVersion }, + 'incoming project structure updates are out of order' + ) + throw new Errors.OpsOutOfOrderError( + 'project structure version out of order on incoming updates' + ) + } else if (alreadySeenDocVersion(incomingDocVersions, update)) { + logger.warn( + { projectId, update, incomingDocVersions }, + 'incoming doc updates are out of order' + ) + throw new Errors.OpsOutOfOrderError( + 'doc version out of order on incoming updates' + ) + } + // update the current project structure and doc versions + if (UpdateTranslator.isProjectStructureUpdate(update)) { + incomingProjectStructureVersion = update.version + } else if (UpdateTranslator.isTextUpdate(update)) { + incomingDocVersions[update.doc] = { v: update.v } + } + } + + // discard updates already applied + const updatesToApply = [] + const previousProjectStructureVersion = projectStructureAndDocVersions.project + const previousDocVersions = projectStructureAndDocVersions.docs + if (projectStructureAndDocVersions != null) { + const updateProjectVersions = [] + for (const update of updates) { + if (update != null && update.version != null) { + updateProjectVersions.push(update.version) + } + } + logger.debug( + { projectId, projectStructureAndDocVersions, updateProjectVersions }, + 'comparing updates with existing project versions' + ) + } + for (const update of updates) { + if (alreadySeenProjectVersion(previousProjectStructureVersion, update)) { + metrics.inc('updates.discarded_project_structure_version') + logger.debug( + { projectId, update, previousProjectStructureVersion }, + 'discarding previously applied project structure update' + ) + continue + } + if (alreadySeenDocVersion(previousDocVersions, update)) { + metrics.inc('updates.discarded_doc_version') + logger.debug( + { projectId, update, previousDocVersions }, + 'discarding previously applied doc update' + ) + continue + } + // remove non-BMP characters from resync updates that have bypassed the normal docupdater flow + _sanitizeUpdate(update) + // if all checks above are ok then accept the update + updatesToApply.push(update) + } + + return updatesToApply +} + +export function _skipAlreadyAppliedUpdates(...args) { + return _mocks._skipAlreadyAppliedUpdates(...args) +} + +function _sanitizeUpdate(update) { + // adapted from docupdater's UpdateManager, we should clean these in docupdater + // too but we already have queues with this problem so we will handle it here + // too for robustness. + // Replace high and low surrogate characters with 'replacement character' (\uFFFD) + const removeBadChars = str => str.replace(/[\uD800-\uDFFF]/g, '\uFFFD') + // clean up any bad chars in resync diffs + if (update.op) { + for (const op of update.op) { + if (op.i != null) { + op.i = removeBadChars(op.i) + } + } + } + // clean up any bad chars in resync new docs + if (update.docLines != null) { + update.docLines = removeBadChars(update.docLines) + } + return update +} + +export const promises = { + processUpdatesForProject: promisify(processUpdatesForProject), +} diff --git a/services/project-history/app/js/Validation.js b/services/project-history/app/js/Validation.js new file mode 100644 index 0000000000..846cc12e24 --- /dev/null +++ b/services/project-history/app/js/Validation.js @@ -0,0 +1,12 @@ +import { celebrate, errors } from 'celebrate' + +export { Joi } from 'celebrate' + +export const errorMiddleware = errors() + +/** + * Validation middleware + */ +export function validate(schema) { + return celebrate(schema, { allowUnknown: true }) +} diff --git a/services/project-history/app/js/Versions.js b/services/project-history/app/js/Versions.js new file mode 100644 index 0000000000..0733b20971 --- /dev/null +++ b/services/project-history/app/js/Versions.js @@ -0,0 +1,68 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +// Compare Versions like 1.2 < 4.1 + +const convertToArray = v => Array.from(v.split('.')).map(x => parseInt(x, 10)) + +const cmp = function (v1, v2) { + // allow comparison to work with integers + if (typeof v1 === 'number' && typeof v2 === 'number') { + if (v1 > v2) { + return +1 + } + if (v1 < v2) { + return -1 + } + // otherwise equal + return 0 + } + // comparison with strings + v1 = convertToArray(v1) + v2 = convertToArray(v2) + while (v1.length || v2.length) { + const [x, y] = Array.from([v1.shift(), v2.shift()]) + if (x > y) { + return +1 + } + if (x < y) { + return -1 + } + if (x != null && y == null) { + return +1 + } + if (x == null && y != null) { + return -1 + } + } + return 0 +} + +export function compare(v1, v2) { + return cmp(v1, v2) +} + +export function gt(v1, v2) { + return cmp(v1, v2) > 0 +} + +export function lt(v1, v2) { + return cmp(v1, v2) < 0 +} + +export function gte(v1, v2) { + return cmp(v1, v2) >= 0 +} + +export function lte(v1, v2) { + return cmp(v1, v2) <= 0 +} diff --git a/services/project-history/app/js/WebApiManager.js b/services/project-history/app/js/WebApiManager.js new file mode 100644 index 0000000000..bbe707b7b9 --- /dev/null +++ b/services/project-history/app/js/WebApiManager.js @@ -0,0 +1,96 @@ +import { promisify } from 'util' +import request from 'requestretry' // allow retry on error https://github.com/FGRibreau/node-request-retry +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' +import Settings from '@overleaf/settings' +import * as Errors from './Errors.js' +import * as RedisManager from './RedisManager.js' + +// Don't let HTTP calls hang for a long time +const DEFAULT_MAX_HTTP_REQUEST_LENGTH = 16000 // 16 seconds + +export function getHistoryId(projectId, callback) { + Metrics.inc('history_id_cache_requests_total') + RedisManager.getCachedHistoryId(projectId, (err, cachedHistoryId) => { + if (err) return callback(err) + if (cachedHistoryId) { + Metrics.inc('history_id_cache_hits_total') + callback(null, cachedHistoryId, true) + } else { + _getProjectDetails(projectId, function (error, project) { + if (error) { + return callback(error) + } + const historyId = + project.overleaf && + project.overleaf.history && + project.overleaf.history.id + if (historyId != null) { + RedisManager.setCachedHistoryId(projectId, historyId, err => { + if (err) return callback(err) + callback(null, historyId, false) + }) + } else { + callback(null, historyId, false) + } + }) + } + }) +} + +export function requestResync(projectId, callback) { + const path = `/project/${projectId}/history/resync` + _sendRequest( + { path, timeout: 6 * 60000, maxAttempts: 1, method: 'POST' }, + callback + ) +} + +function _getProjectDetails(projectId, callback) { + const path = `/project/${projectId}/details` + logger.debug({ projectId }, 'getting project details from web') + _sendRequest({ path, json: true }, callback) +} + +function _sendRequest(options, callback) { + const url = `${Settings.apis.web.url}${options.path}` + request( + { + method: options.method || 'GET', + url, + json: options.json || false, + timeout: options.timeout || DEFAULT_MAX_HTTP_REQUEST_LENGTH, + maxAttempts: options.maxAttempts || 2, // for node-request-retry + auth: { + user: Settings.apis.web.user, + pass: Settings.apis.web.pass, + sendImmediately: true, + }, + }, + function (error, res, body) { + if (error != null) { + return callback(OError.tag(error)) + } + if (res.statusCode === 404) { + logger.debug({ url }, 'got 404 from web api') + error = new Errors.NotFoundError('got a 404 from web api') + return callback(error) + } + if (res.statusCode >= 200 && res.statusCode < 300) { + callback(null, body) + } else { + error = new OError( + `web returned a non-success status code: ${res.statusCode} (attempts: ${res.attempts})`, + { url, res } + ) + callback(error) + } + } + ) +} + +export const promises = { + getHistoryId: promisify(getHistoryId), + requestResync: promisify(requestResync), +} diff --git a/services/project-history/app/js/mongodb.js b/services/project-history/app/js/mongodb.js new file mode 100644 index 0000000000..eb7814bc59 --- /dev/null +++ b/services/project-history/app/js/mongodb.js @@ -0,0 +1,15 @@ +import Settings from '@overleaf/settings' +import { MongoClient } from 'mongodb' + +export { ObjectId } from 'mongodb' + +export const mongoClient = new MongoClient(Settings.mongo.url) +const mongoDb = mongoClient.db() + +export const db = { + deletedProjects: mongoDb.collection('deletedProjects'), + projects: mongoDb.collection('projects'), + projectHistoryFailures: mongoDb.collection('projectHistoryFailures'), + projectHistoryLabels: mongoDb.collection('projectHistoryLabels'), + projectHistorySyncState: mongoDb.collection('projectHistorySyncState'), +} diff --git a/services/project-history/app/js/server.js b/services/project-history/app/js/server.js new file mode 100644 index 0000000000..7aae484f4b --- /dev/null +++ b/services/project-history/app/js/server.js @@ -0,0 +1,65 @@ +import Settings from '@overleaf/settings' +import Metrics from '@overleaf/metrics' +import logger from '@overleaf/logger' +import express from 'express' +import bodyParser from 'body-parser' +import * as Errors from './Errors.js' +import * as Router from './Router.js' +import * as Validation from './Validation.js' + +const HistoryLogger = logger.initialize('project-history').logger + +if (Settings.sentry.dsn) { + logger.initializeErrorReporting(Settings.sentry.dsn) +} + +Metrics.initialize('project-history') +Metrics.event_loop.monitor(logger) +Metrics.memory.monitor(logger) + +// log updates as truncated strings +function truncateFn(updates) { + return JSON.parse( + JSON.stringify(updates, function (key, value) { + let len + if (typeof value === 'string' && (len = value.length) > 80) { + return ( + value.substr(0, 32) + + `...(message of length ${len} truncated)...` + + value.substr(-32) + ) + } else { + return value + } + }) + ) +} + +HistoryLogger.addSerializers({ + rawUpdate: truncateFn, + rawUpdates: truncateFn, + newUpdates: truncateFn, + lastUpdate: truncateFn, +}) + +export const app = express() +app.use(bodyParser.json()) +app.use(bodyParser.urlencoded({ extended: true })) +app.use(Metrics.http.monitor(logger)) +Router.initialize(app) +Metrics.injectMetricsRoute(app) +app.use(Validation.errorMiddleware) +app.use(function (error, req, res, next) { + if (error instanceof Errors.NotFoundError) { + res.sendStatus(404) + } else if (error instanceof Errors.BadRequestError) { + res.sendStatus(400) + } else if (error instanceof Errors.InconsistentChunkError) { + res.sendStatus(422) + } else if (error instanceof Errors.TooManyRequestsError) { + res.status(429).set('Retry-After', 300).end() + } else { + logger.error({ err: error, req }, error.message) + res.status(500).json({ message: 'an internal error occurred' }) + } +}) diff --git a/services/project-history/buildscript.txt b/services/project-history/buildscript.txt new file mode 100644 index 0000000000..5cb01f49ea --- /dev/null +++ b/services/project-history/buildscript.txt @@ -0,0 +1,8 @@ +project-history +--dependencies=mongo,redis +--docker-repos=gcr.io/overleaf-ops +--env-add= +--env-pass-through= +--node-version=16.17.1 +--public-repo=False +--script-version=4.1.0 diff --git a/services/project-history/config/settings.defaults.cjs b/services/project-history/config/settings.defaults.cjs new file mode 100644 index 0000000000..b0ea5feec0 --- /dev/null +++ b/services/project-history/config/settings.defaults.cjs @@ -0,0 +1,99 @@ +module.exports = { + mongo: { + url: + process.env.MONGO_CONNECTION_STRING || + `mongodb://${process.env.MONGO_HOST || 'localhost'}/sharelatex`, + }, + internal: { + history: { + port: 3054, + host: process.env.LISTEN_ADDRESS || 'localhost', + }, + }, + apis: { + documentupdater: { + url: `http://${process.env.DOCUPDATER_HOST || 'localhost'}:3003`, + }, + docstore: { + url: `http://${process.env.DOCSTORE_HOST || 'localhost'}:3016`, + }, + filestore: { + url: `http://${process.env.FILESTORE_HOST || 'localhost'}:3009`, + }, + web: { + url: `http://${ + process.env.WEB_API_HOST || process.env.WEB_HOST || 'localhost' + }:${process.env.WEB_PORT || 3000}`, + user: process.env.WEB_API_USER || 'sharelatex', + pass: process.env.WEB_API_PASSWORD || 'password', + historyIdCacheSize: parseInt( + process.env.HISTORY_ID_CACHE_SIZE || '10000', + 10 + ), + }, + }, + redis: { + lock: { + host: process.env.REDIS_HOST || 'localhost', + password: process.env.REDIS_PASSWORD, + port: process.env.REDIS_PORT || 6379, + key_schema: { + projectHistoryLock({ project_id: projectId }) { + return `ProjectHistoryLock:{${projectId}}` + }, + }, + }, + project_history: { + host: + process.env.HISTORY_REDIS_HOST || process.env.REDIS_HOST || 'localhost', + port: process.env.HISTORY_REDIS_PORT || process.env.REDIS_PORT || 6379, + password: + process.env.HISTORY_REDIS_PASSWORD || process.env.REDIS_PASSWORD, + key_schema: { + projectHistoryOps({ project_id: projectId }) { + return `ProjectHistory:Ops:{${projectId}}` + }, + projectHistoryFirstOpTimestamp({ project_id: projectId }) { + return `ProjectHistory:FirstOpTimestamp:{${projectId}}` + }, + projectHistoryCachedHistoryId({ project_id: projectId }) { + return `ProjectHistory:CachedHistoryId:{${projectId}}` + }, + }, + }, + }, + + history: { + healthCheck: { + project_id: process.env.HEALTH_CHECK_PROJECT_ID || '', + }, + }, + + overleaf: { + history: { + host: + process.env.V1_HISTORY_FULL_HOST || + `http://${ + process.env.V1_HISTORY_HOST || + process.env.HISTORY_V1_HOST || + 'localhost' + }:3100/api`, + user: process.env.V1_HISTORY_USER || 'staging', + pass: process.env.V1_HISTORY_PASSWORD || 'password', + sync: { + retries_max: 30, + interval: 2, + }, + }, + }, + + path: { + uploadFolder: process.env.UPLOAD_FOLDER || '/tmp/', + }, + + sentry: { + dsn: process.env.SENTRY_DSN, + }, + + maxFileSizeInBytes: 100 * 1024 * 1024, // 100 megabytes +} diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml new file mode 100644 index 0000000000..e536ed999d --- /dev/null +++ b/services/project-history/docker-compose.ci.yml @@ -0,0 +1,58 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +version: "2.3" + +services: + test_unit: + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: node + command: npm run test:unit:_run + environment: + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + + + test_acceptance: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + ANALYTICS_QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + user: node + command: npm run test:acceptance:_run + + + tar: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + volumes: + - ./:/tmp/build/ + command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . + user: root + redis: + image: redis + healthcheck: + test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] + interval: 1s + retries: 20 + + mongo: + image: mongo:4.4.16 + healthcheck: + test: "mongo --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'" + interval: 1s + retries: 20 diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml new file mode 100644 index 0000000000..9e85657a3a --- /dev/null +++ b/services/project-history/docker-compose.yml @@ -0,0 +1,61 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/overleaf/internal/ + +version: "2.3" + +services: + test_unit: + image: node:16.17.1 + volumes: + - .:/overleaf/services/project-history + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/project-history + environment: + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + command: npm run --silent test:unit + user: node + + test_acceptance: + image: node:16.17.1 + volumes: + - .:/overleaf/services/project-history + - ../../node_modules:/overleaf/node_modules + - ../../libraries:/overleaf/libraries + working_dir: /overleaf/services/project-history + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + ANALYTICS_QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ERROR + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + command: npm run --silent test:acceptance + + redis: + image: redis + healthcheck: + test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] + interval: 1s + retries: 20 + + mongo: + image: mongo:4.4.16 + healthcheck: + test: "mongo --quiet localhost/test --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 1)'" + interval: 1s + retries: 20 + diff --git a/services/project-history/nodemon.json b/services/project-history/nodemon.json new file mode 100644 index 0000000000..58f7956f20 --- /dev/null +++ b/services/project-history/nodemon.json @@ -0,0 +1,18 @@ +{ + "ignore": [ + ".git", + "node_modules/" + ], + "verbose": true, + "legacyWatch": true, + "execMap": { + "js": "npm run start" + }, + "watch": [ + "app/js/", + "app.js", + "config/", + "../../libraries/" + ], + "ext": "js" +} diff --git a/services/project-history/package.json b/services/project-history/package.json new file mode 100644 index 0000000000..f9b48a928c --- /dev/null +++ b/services/project-history/package.json @@ -0,0 +1,59 @@ +{ + "name": "@overleaf/project-history", + "description": "An API for saving and compressing individual document updates into a browseable history", + "private": true, + "main": "app.js", + "type": "module", + "scripts": { + "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", + "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", + "start": "node $NODE_APP_OPTIONS app.js", + "nodemon": "nodemon --config nodemon.json", + "test:acceptance:_run": "LOG_LEVEL=error mocha --loader=esmock --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js", + "test:unit:_run": "LOG_LEVEL=fatal mocha --loader=esmock --recursive --reporter spec $@ test/unit/js", + "lint": "eslint --max-warnings 0 --format unix .", + "format": "prettier --list-different $PWD/'**/*.js'", + "format:fix": "prettier --write $PWD/'**/*.js'", + "lint:fix": "eslint --fix ." + }, + "dependencies": { + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "@overleaf/o-error": "*", + "@overleaf/redis-wrapper": "*", + "@overleaf/settings": "*", + "async": "^3.2.2", + "aws-sdk": "^2.650.0", + "bluebird": "^3.7.2", + "body-parser": "^1.19.0", + "bunyan": "^1.8.15", + "byline": "^4.2.1", + "celebrate": "^10.1.0", + "cli": "^1.0.1", + "diff-match-patch": "https://github.com/overleaf/diff-match-patch/archive/89805f9c671a77a263fc53461acd62aa7498f688.tar.gz", + "esmock": "^2.1.0", + "express": "4.17.1", + "heap": "^0.2.6", + "JSONStream": "^1.3.5", + "line-reader": "^0.2.4", + "lodash": "^4.17.20", + "mongo-uri": "^0.1.2", + "mongodb": "^4.11.0", + "overleaf-editor-core": "*", + "redis": "~0.10.1", + "request": "^2.88.2", + "requestretry": "^1.12.2", + "uuid": "^9.0.0" + }, + "devDependencies": { + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "memorystream": "0.3.1", + "mocha": "^8.4.0", + "multer": "^1.4.2", + "nock": "^12.0.3", + "sinon": "~9.0.1", + "sinon-chai": "^3.7.0", + "timekeeper": "2.2.0" + } +} diff --git a/services/project-history/scripts/add_index_for_sync_state.js b/services/project-history/scripts/add_index_for_sync_state.js new file mode 100644 index 0000000000..0c171975b4 --- /dev/null +++ b/services/project-history/scripts/add_index_for_sync_state.js @@ -0,0 +1,21 @@ +/* eslint-env mongo */ + +// add a TTL index to expire entries for completed resyncs in the +// projectHistorySyncState collection. The entries should only be expired if +// resyncProjectStructure is false and resyncDocContents is a zero-length array. + +const now = Date.now() +const inTheFuture = now + 24 * 3600 * 1000 + +db.projectHistorySyncState.ensureIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0, background: true } +) +db.projectHistorySyncState.updateMany( + { + resyncProjectStructure: false, + resyncDocContents: [], + expiresAt: { $exists: false }, + }, + { $set: { expiresAt: new Date(inTheFuture) } } +) diff --git a/services/project-history/scripts/clear_dangling_timestamps.js b/services/project-history/scripts/clear_dangling_timestamps.js new file mode 100644 index 0000000000..8779006495 --- /dev/null +++ b/services/project-history/scripts/clear_dangling_timestamps.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +// Clear timestamps which don't have any corresponding history ops +// usage: scripts/flush_all.js + +import logger from '@overleaf/logger' +import * as RedisManager from '../app/js/RedisManager.js' + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null + +// find all dangling timestamps and clear them +async function main() { + logger.info( + { limit }, + 'running redis scan for project timestamps, this may take a while' + ) + const projectIdsWithFirstOpTimestamps = + await RedisManager.promises.getProjectIdsWithFirstOpTimestamps(limit) + const totalTimestamps = projectIdsWithFirstOpTimestamps.length + logger.info( + { totalTimestamps }, + 'scan completed, now clearing dangling timestamps' + ) + let clearedTimestamps = 0 + let processed = 0 + for (const projectId of projectIdsWithFirstOpTimestamps) { + const result = await RedisManager.promises.clearDanglingFirstOpTimestamp( + projectId + ) + processed++ + clearedTimestamps += result + if (processed % 1000 === 0) { + logger.info( + { processed, totalTimestamps, clearedTimestamps }, + 'clearing timestamps' + ) + } + } + logger.info({ processed, totalTimestamps, clearedTimestamps }, 'completed') + process.exit(0) +} + +main() diff --git a/services/project-history/scripts/clear_deleted.js b/services/project-history/scripts/clear_deleted.js new file mode 100755 index 0000000000..00519326ae --- /dev/null +++ b/services/project-history/scripts/clear_deleted.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import async from 'async' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import { db, ObjectId } from '../app/js/mongodb.js' + +logger.logger.level('fatal') + +const rclient = redis.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const force = argv[1] === 'force' || false +let delay = 0 + +function checkAndClear(project, callback) { + const projectId = project.project_id + function checkDeleted(cb) { + db.projects.findOne( + { _id: ObjectId(projectId) }, + { projection: { _id: 1 } }, + (err, result) => { + if (err) { + cb(err) + } else if (!result) { + // project not found, but we still need to look at deletedProjects + cb() + } else { + console.log(`Project ${projectId} found in projects`) + cb(new Error('error: project still exists')) + } + } + ) + } + + function checkRecoverable(cb) { + db.deletedProjects.findOne( + { + // this condition makes use of the index + 'deleterData.deletedProjectId': ObjectId(projectId), + // this condition checks if the deleted project has expired + 'project._id': ObjectId(projectId), + }, + { projection: { _id: 1 } }, + (err, result) => { + if (err) { + cb(err) + } else if (!result) { + console.log( + `project ${projectId} has been deleted - safe to clear queue` + ) + cb() + } else { + console.log(`Project ${projectId} found in deletedProjects`) + cb(new Error('error: project still exists')) + } + } + ) + } + + function clearRedisQueue(cb) { + const key = Keys.projectHistoryOps({ project_id: projectId }) + delay++ + if (force) { + console.log('setting redis key', key, 'to expire in', delay, 'seconds') + // use expire to allow redis to delete the key in the background + rclient.expire(key, delay, err => { + cb(err) + }) + } else { + console.log( + 'dry run, would set key', + key, + 'to expire in', + delay, + 'seconds' + ) + cb() + } + } + + function clearMongoEntry(cb) { + if (force) { + console.log('deleting key in mongo projectHistoryFailures', projectId) + db.projectHistoryFailures.deleteOne({ project_id: projectId }, cb) + } else { + console.log('would delete failure record for', projectId, 'from mongo') + cb() + } + } + + // do the checks and deletions + async.waterfall( + [checkDeleted, checkRecoverable, clearRedisQueue, clearMongoEntry], + err => { + if (!err || err.message === 'error: project still exists') { + callback() + } else { + console.log('error:', err) + callback(err) + } + } + ) +} + +// find all the broken projects from the failure records +async function main() { + const results = await db.projectHistoryFailures.find({}).toArray() + processFailures(results) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) + +function processFailures(results) { + if (argv.length === 0) { + console.log(` +Usage: node clear_deleted.js [QUEUES] [FORCE] + +where + QUEUES is the number of queues to process + FORCE is the string "force" when we're ready to delete the queues. Without it, this script does a dry-run +`) + } + console.log('number of stuck projects', results.length) + // now check if the project is truly deleted in mongo + async.eachSeries(results.slice(0, limit), checkAndClear, err => { + console.log('DONE', err) + process.exit() + }) +} diff --git a/services/project-history/scripts/clear_deleted_history.js b/services/project-history/scripts/clear_deleted_history.js new file mode 100755 index 0000000000..b47dd7b583 --- /dev/null +++ b/services/project-history/scripts/clear_deleted_history.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +// To run in dev: +// +// docker-compose run --rm project-history scripts/clear_deleted.js +// +// In production: +// +// docker run --rm $(docker ps -lq) scripts/clear_deleted.js + +import async from 'async' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import { db, ObjectId } from '../app/js/mongodb.js' + +logger.logger.level('fatal') + +const rclient = redis.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const force = argv[1] === 'force' || false +let projectNotFoundErrors = 0 +let projectImportedFromV1Errors = 0 +const projectsNotFound = [] +const projectsImportedFromV1 = [] +let projectWithHistoryIdErrors = 0 +const projectsWithHistoryId = [] + +function checkAndClear(project, callback) { + const projectId = project.project_id + console.log('checking project', projectId) + + function checkDeleted(cb) { + db.projects.findOne( + { _id: ObjectId(projectId) }, + { projection: { overleaf: true } }, + (err, result) => { + console.log( + '1. looking in mongo projects collection: err', + err, + 'result', + JSON.stringify(result) + ) + if (err) { + return cb(err) + } + if (!result) { + return cb(new Error('project not found in mongo')) + } + if ( + result && + result.overleaf && + !result.overleaf.id && + result.overleaf.history && + !result.overleaf.history.id && + result.overleaf.history.deleted_id + ) { + console.log( + ' - project is not imported from v1 and has a deleted_id - ok to clear' + ) + return cb() + } else if (result && result.overleaf && result.overleaf.id) { + console.log(' - project is imported from v1') + return cb( + new Error('project is imported from v1 - will not clear it') + ) + } else if ( + result && + result.overleaf && + result.overleaf.history && + result.overleaf.history.id + ) { + console.log(' - project has a history id') + return cb(new Error('project has a history id - will not clear it')) + } else { + console.log(' - project state not recognised') + return cb(new Error('project state not recognised')) + } + } + ) + } + + function clearRedisQueue(cb) { + const key = Keys.projectHistoryOps({ project_id: projectId }) + if (force) { + console.log('deleting redis key', key) + rclient.del(key, err => { + cb(err) + }) + } else { + console.log('dry run, would deleted key', key) + cb() + } + } + + function clearMongoEntry(cb) { + if (force) { + console.log('deleting key in mongo projectHistoryFailures', projectId) + db.projectHistoryFailures.deleteOne( + { project_id: projectId }, + (err, result) => { + console.log('got result from remove', err, result) + cb(err) + } + ) + } else { + console.log('would delete failure record for', projectId, 'from mongo') + cb() + } + } + + // do the checks and deletions + async.waterfall([checkDeleted, clearRedisQueue, clearMongoEntry], err => { + if (!err) { + if (force) { + return setTimeout(callback, 100) + } // include a 1 second delay + return callback() + } else if (err.message === 'project not found in mongo') { + projectNotFoundErrors++ + projectsNotFound.push(projectId) + return callback() + } else if (err.message === 'project has a history id - will not clear it') { + projectWithHistoryIdErrors++ + projectsWithHistoryId.push(projectId) + return callback() + } else if ( + err.message === 'project is imported from v1 - will not clear it' + ) { + projectImportedFromV1Errors++ + projectsImportedFromV1.push(projectId) + return callback() + } else { + console.log('error:', err) + return callback(err) + } + }) +} + +// find all the broken projects from the failure records +async function main() { + const results = await db.projectHistoryFailures + .find({ error: 'Error: history store a non-success status code: 422' }) + .toArray() + + console.log('number of queues without history store 442 =', results.length) + // now check if the project is truly deleted in mongo + async.eachSeries(results.slice(0, limit), checkAndClear, err => { + console.log('Final error status', err) + console.log( + 'Project not found errors', + projectNotFoundErrors, + projectsNotFound + ) + console.log( + 'Project with history id errors', + projectWithHistoryIdErrors, + projectsWithHistoryId + ) + console.log( + 'Project imported from V1 errors', + projectImportedFromV1Errors, + projectsImportedFromV1 + ) + process.exit() + }) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/services/project-history/scripts/clear_filestore_404.js b/services/project-history/scripts/clear_filestore_404.js new file mode 100755 index 0000000000..4f74957e39 --- /dev/null +++ b/services/project-history/scripts/clear_filestore_404.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +// To run in dev: +// +// docker-compose run --rm project-history scripts/clear_deleted.js +// +// In production: +// +// docker run --rm $(docker ps -lq) scripts/clear_deleted.js + +import async from 'async' +import logger from '@overleaf/logger' +import request from 'request' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import { db, ObjectId } from '../app/js/mongodb.js' + +logger.logger.level('fatal') + +const rclient = redis.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const force = argv[1] === 'force' || false +let projectNotFoundErrors = 0 +let projectImportedFromV1Errors = 0 +const projectsNotFound = [] +const projectsImportedFromV1 = [] + +function checkAndClear(project, callback) { + const projectId = project.project_id + console.log('checking project', projectId) + + // These can probably also be reset and their overleaf.history.id unset + // (unless they are v1 projects). + + function checkNotV1Project(cb) { + db.projects.findOne( + { _id: ObjectId(projectId) }, + { projection: { overleaf: true } }, + (err, result) => { + console.log( + '1. looking in mongo projects collection: err', + err, + 'result', + JSON.stringify(result) + ) + if (err) { + return cb(err) + } + if (!result) { + return cb(new Error('project not found in mongo')) + } + if (result && result.overleaf && !result.overleaf.id) { + console.log(' - project is not imported from v1 - ok to clear') + cb() + } else { + cb(new Error('project is imported from v1 - will not clear it')) + } + } + ) + } + + function clearProjectHistoryInMongo(cb) { + if (force) { + console.log('2. deleting overleaf.history.id in mongo project', projectId) + // Accessing mongo projects collection directly - BE CAREFUL! + db.projects.updateOne( + { _id: ObjectId(projectId) }, + { $unset: { 'overleaf.history.id': '' } }, + (err, result) => { + console.log(' - got result from remove', err, result) + if (err) { + return err + } + if ( + result && + (result.modifiedCount === 1 || result.modifiedCount === 0) + ) { + return cb() + } else { + return cb( + new Error('error: problem trying to unset overleaf.history.id') + ) + } + } + ) + } else { + console.log( + '2. would delete overleaf.history.id for', + projectId, + 'from mongo' + ) + cb() + } + } + + function clearDocUpdaterCache(cb) { + const url = Settings.apis.documentupdater.url + '/project/' + projectId + if (force) { + console.log('3. making request to clear docupdater', url) + request.delete(url, (err, response, body) => { + console.log( + ' - result of request', + err, + response && response.statusCode, + body + ) + cb(err) + }) + } else { + console.log('3. dry run, would request DELETE on url', url) + cb() + } + } + + function clearRedisQueue(cb) { + const key = Keys.projectHistoryOps({ project_id: projectId }) + if (force) { + console.log('4. deleting redis queue key', key) + rclient.del(key, err => { + cb(err) + }) + } else { + console.log('4. dry run, would delete redis key', key) + cb() + } + } + + function clearMongoEntry(cb) { + if (force) { + console.log('5. deleting key in mongo projectHistoryFailures', projectId) + db.projectHistoryFailures.deleteOne( + { project_id: projectId }, + (err, result) => { + console.log(' - got result from remove', err, result) + cb(err) + } + ) + } else { + console.log('5. would delete failure record for', projectId, 'from mongo') + cb() + } + } + + // do the checks and deletions + async.waterfall( + [ + checkNotV1Project, + clearProjectHistoryInMongo, + clearDocUpdaterCache, + clearRedisQueue, + clearMongoEntry, + ], + err => { + if (!err) { + return setTimeout(callback, 1000) // include a 1 second delay + } else if (err.message === 'project not found in mongo') { + projectNotFoundErrors++ + projectsNotFound.push(projectId) + return callback() + } else if ( + err.message === 'project is imported from v1 - will not clear it' + ) { + projectImportedFromV1Errors++ + projectsImportedFromV1.push(projectId) + return callback() + } else { + console.log('error:', err) + return callback(err) + } + } + ) +} + +// find all the broken projects from the failure records +async function main() { + const results = await db.projectHistoryFailures + .find({ error: 'Error: bad response from filestore: 404' }) + .toArray() + + console.log('number of queues without filestore 404 =', results.length) + // now check if the project is truly deleted in mongo + async.eachSeries(results.slice(0, limit), checkAndClear, err => { + console.log('Final error status', err) + console.log( + 'Project not found errors', + projectNotFoundErrors, + projectsNotFound + ) + console.log( + 'Project imported from V1 errors', + projectImportedFromV1Errors, + projectsImportedFromV1 + ) + process.exit() + }) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/services/project-history/scripts/clear_project_version_out_of_order.js b/services/project-history/scripts/clear_project_version_out_of_order.js new file mode 100755 index 0000000000..fab7a63126 --- /dev/null +++ b/services/project-history/scripts/clear_project_version_out_of_order.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +// To run in dev: +// +// docker-compose run --rm project-history scripts/clear_deleted.js +// +// In production: +// +// docker run --rm $(docker ps -lq) scripts/clear_deleted.js + +import async from 'async' +import logger from '@overleaf/logger' +import request from 'request' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import { db, ObjectId } from '../app/js/mongodb.js' + +logger.logger.level('fatal') + +const rclient = redis.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const force = argv[1] === 'force' || false +let projectNotFoundErrors = 0 +let projectImportedFromV1Errors = 0 +const projectsNotFound = [] +const projectsImportedFromV1 = [] +let projectHasV2HistoryErrors = 0 +const projectsV2HistoryInUse = [] + +function checkAndClear(project, callback) { + const projectId = project.project_id + console.log('checking project', projectId) + + // These can probably also be reset and their overleaf.history.id unset + // (unless they are v1 projects). + + function checkNotV1Project(cb) { + db.projects.findOne( + { _id: ObjectId(projectId) }, + { projection: { overleaf: true } }, + (err, result) => { + console.log( + '1. looking in mongo projects collection: err', + err, + 'result', + JSON.stringify(result) + ) + if (err) { + return cb(err) + } + if (!result) { + return cb(new Error('project not found in mongo')) + } + + const isV1Project = result && result.overleaf && result.overleaf.id + const hasHistoryId = + result && + result.overleaf && + result.overleaf.history && + result.overleaf.history.id + const hasV2HistoryInUse = + result && + result.overleaf && + result.overleaf.history && + result.overleaf.history.display + const hasExistingDeletedHistory = + result && + result.overleaf.history && + result.overleaf.history.deleted_id + if ( + hasHistoryId && + !(isV1Project || hasV2HistoryInUse || hasExistingDeletedHistory) + ) { + console.log( + ' - project is not imported from v1 and v2 history is not in use - ok to clear' + ) + return cb() + } else if (hasHistoryId && hasExistingDeletedHistory) { + console.log(' - project already has deleted_id') + return cb( + new Error('project already has deleted_id - will not clear it') + ) + } else if (hasHistoryId && isV1Project) { + console.log(' - project is imported from v1') + return cb( + new Error('project is imported from v1 - will not clear it') + ) + } else if (hasHistoryId && hasV2HistoryInUse) { + console.log(' - project is displaying v2 history') + return cb( + new Error('project is displaying v2 history - will not clear it') + ) + } else { + console.log(' - project state not recognised') + return cb(new Error('project state not recognised')) + } + } + ) + } + + function clearProjectHistoryInMongo(cb) { + if (force) { + console.log('2. deleting overleaf.history.id in mongo project', projectId) + // Accessing mongo projects collection directly - BE CAREFUL! + db.projects.updateOne( + { _id: ObjectId(projectId) }, + { $rename: { 'overleaf.history.id': 'overleaf.history.deleted_id' } }, + (err, result) => { + console.log(' - got result from remove', err, result) + if (err) { + return err + } + if ( + result && + (result.modifiedCount === 1 || result.modifiedCount === 0) + ) { + return cb() + } else { + return cb( + new Error('error: problem trying to unset overleaf.history.id') + ) + } + } + ) + } else { + console.log( + '2. would delete overleaf.history.id for', + projectId, + 'from mongo' + ) + cb() + } + } + + function clearDocUpdaterCache(cb) { + const url = Settings.apis.documentupdater.url + '/project/' + projectId + if (force) { + console.log('3. making request to clear docupdater', url) + request.delete(url, (err, response, body) => { + console.log( + ' - result of request', + err, + response && response.statusCode, + body + ) + cb(err) + }) + } else { + console.log('3. dry run, would request DELETE on url', url) + cb() + } + } + + function clearRedisQueue(cb) { + const key = Keys.projectHistoryOps({ project_id: projectId }) + if (force) { + console.log('4. deleting redis queue key', key) + rclient.del(key, err => { + cb(err) + }) + } else { + console.log('4. dry run, would delete redis key', key) + cb() + } + } + + function clearMongoEntry(cb) { + if (force) { + console.log('5. deleting key in mongo projectHistoryFailures', projectId) + db.projectHistoryFailures.deleteOne( + { project_id: projectId }, + (err, result) => { + console.log(' - got result from remove', err, result) + cb(err) + } + ) + } else { + console.log('5. would delete failure record for', projectId, 'from mongo') + cb() + } + } + + // do the checks and deletions + async.waterfall( + [ + checkNotV1Project, + clearProjectHistoryInMongo, + clearDocUpdaterCache, + clearRedisQueue, + clearMongoEntry, + ], + err => { + if (!err) { + return setTimeout(callback, 100) // include a delay + } else if (err.message === 'project not found in mongo') { + projectNotFoundErrors++ + projectsNotFound.push(projectId) + return callback() + } else if ( + err.message === 'project is imported from v1 - will not clear it' + ) { + projectImportedFromV1Errors++ + projectsImportedFromV1.push(projectId) + return callback() + } else if ( + err.message === 'project is displaying v2 history - will not clear it' + ) { + projectHasV2HistoryErrors++ + projectsV2HistoryInUse.push(projectId) + return callback() + } else { + console.log('error:', err) + return callback(err) + } + } + ) +} + +// find all the broken projects from the failure records +async function main() { + const results = await db.projectHistoryFailures + .find({ + error: + 'OpsOutOfOrderError: project structure version out of order on incoming updates', + }) + .toArray() + + console.log( + 'number of queues with project structure version out of order on incoming updates=', + results.length + ) + // now clear the sharelatex projects + async.eachSeries(results.slice(0, limit), checkAndClear, err => { + console.log('Final error status', err) + console.log( + 'Project not found errors', + projectNotFoundErrors, + projectsNotFound + ) + console.log( + 'Project imported from V1 errors', + projectImportedFromV1Errors, + projectsImportedFromV1 + ) + console.log( + 'Project has V2 history in use', + projectHasV2HistoryErrors, + projectsV2HistoryInUse + ) + process.exit() + }) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/services/project-history/scripts/debug_translate_updates.js b/services/project-history/scripts/debug_translate_updates.js new file mode 100755 index 0000000000..cd6b324223 --- /dev/null +++ b/services/project-history/scripts/debug_translate_updates.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +/** + * This script takes a dump file, obtained via the /project/:project_id/dump + * endpoint and feeds it to the update translator to how updates are transfomed + * into changes sent to v1 history. + */ +import fs from 'fs' +import * as UpdateTranslator from '../app/js/UpdateTranslator.js' +import * as SyncManager from '../app/js/SyncManager.js' +import * as HistoryStoreManager from '../app/js/HistoryStoreManager.js' + +const { filename } = parseArgs() +const { projectId, updates, chunk } = parseDumpFile(filename) + +function expandResyncProjectStructure(chunk, update) { + HistoryStoreManager._mocks.getMostRecentChunk = function ( + projectId, + projectHistoryId, + callback + ) { + callback(null, chunk) + } + + SyncManager.expandSyncUpdates( + projectId, + 99999, // dummy history id + [update], + cb => cb(), // extend lock + (err, result) => { + console.log('err', err, 'result', JSON.stringify(result, null, 2)) + process.exit() + } + ) +} + +function expandUpdates(updates) { + const wrappedUpdates = updates.map(update => ({ update })) + UpdateTranslator.convertToChanges( + projectId, + wrappedUpdates, + (err, changes) => { + if (err != null) { + error(err) + } + console.log(JSON.stringify(changes, null, 2)) + } + ) +} + +if (updates[0].resyncProjectStructure) { + expandResyncProjectStructure(chunk, updates[0]) +} else { + expandUpdates(updates) +} + +function parseArgs() { + const args = process.argv.slice(2) + if (args.length !== 1) { + console.log('Usage: debug_translate_updates.js DUMP_FILE') + process.exit(1) + } + const filename = args[0] + return { filename } +} + +function parseDumpFile(filename) { + const json = fs.readFileSync(filename) + const { project_id: projectId, updates, chunk } = JSON.parse(json) + return { projectId, updates, chunk } +} + +function error(err) { + console.error(err) + process.exit(1) +} diff --git a/services/project-history/scripts/flush_all.js b/services/project-history/scripts/flush_all.js new file mode 100755 index 0000000000..0caac5f2e8 --- /dev/null +++ b/services/project-history/scripts/flush_all.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +// To run in dev: +// +// docker-compose run --rm project-history scripts/flush_all.js +// +// In production: +// +// docker run --rm $(docker ps -lq) scripts/flush_all.js + +import _ from 'lodash' +import async from 'async' +import logger from '@overleaf/logger' +import * as RedisManager from '../app/js/RedisManager.js' +import * as UpdatesProcessor from '../app/js/UpdatesProcessor.js' + +logger.logger.level('fatal') + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const parallelism = Math.min(parseInt(argv[1], 10) || 1, 10) + +// flush all outstanding changes +RedisManager.getProjectIdsWithHistoryOps(limit, flushProjects) + +function flushProjects(error, projectIds) { + if (error) { + throw error + } + let ts = new Date() + console.log( + 'found projects', + JSON.stringify({ project_ids: projectIds.length, limit, ts }) + ) + projectIds = _.shuffle(projectIds) // randomise to avoid hitting same projects each time + if (limit > 0) { + projectIds = projectIds.slice(0, limit) + } + + let succeededProjects = 0 + let failedProjects = 0 + let attempts = 0 + + async.eachLimit( + projectIds, + parallelism, + function (projectId, cb) { + attempts++ + UpdatesProcessor.processUpdatesForProject( + projectId, + function (err, queueSize) { + const progress = attempts + '/' + projectIds.length + ts = new Date() + if (err) { + failedProjects++ + console.log( + 'failed', + progress, + JSON.stringify({ + projectId, + queueSize, + ts, + err: err.toString(), + }) + ) + } else { + succeededProjects++ + console.log( + 'succeeded', + progress, + JSON.stringify({ + projectId, + queueSize, + ts, + }) + ) + } + return cb() + } + ) + }, + function () { + console.log( + 'total', + JSON.stringify({ + succeededProjects, + failedProjects, + }) + ) + process.exit(0) + } + ) +} diff --git a/services/project-history/scripts/force_resync.js b/services/project-history/scripts/force_resync.js new file mode 100755 index 0000000000..71da09794f --- /dev/null +++ b/services/project-history/scripts/force_resync.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node + +// To run in dev: +// +// docker-compose run --rm project-history scripts/clear_deleted.js +// +// In production: +// +// docker run --rm $(docker ps -lq) scripts/clear_deleted.js + +import async from 'async' +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' +import { db, ObjectId } from '../app/js/mongodb.js' +import * as SyncManager from '../app/js/SyncManager.js' +import * as UpdatesProcessor from '../app/js/UpdatesProcessor.js' + +const rclient = redis.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +const argv = process.argv.slice(2) +const limit = parseInt(argv[0], 10) || null +const force = argv[1] === 'force' || false +let projectNotFoundErrors = 0 +let projectImportedFromV1Errors = 0 +const projectsNotFound = [] +const projectsImportedFromV1 = [] +let projectNoHistoryIdErrors = 0 +let projectsFailedErrors = 0 +const projectsFailed = [] +let projectsBrokenSyncErrors = 0 +const projectsBrokenSync = [] + +function checkAndClear(project, callback) { + const projectId = project.project_id + console.log('checking project', projectId) + + // These can probably also be reset and their overleaf.history.id unset + // (unless they are v1 projects). + + function checkNotV1Project(cb) { + db.projects.findOne( + { _id: ObjectId(projectId) }, + { projection: { overleaf: true } }, + (err, result) => { + console.log( + '1. looking in mongo projects collection: err', + err, + 'result', + JSON.stringify(result) + ) + if (err) { + return cb(err) + } + if (!result) { + return cb(new Error('project not found in mongo')) + } + if (result && result.overleaf && !result.overleaf.id) { + if (result.overleaf.history.id) { + console.log( + ' - project is not imported from v1 and has a history id - ok to resync' + ) + return cb() + } else { + console.log( + ' - project is not imported from v1 but does not have a history id' + ) + return cb(new Error('no history id')) + } + } else { + cb(new Error('project is imported from v1 - will not resync it')) + } + } + ) + } + + function startResync(cb) { + if (force) { + console.log('2. starting resync for', projectId) + SyncManager.startResync(projectId, err => { + if (err) { + console.log('ERR', JSON.stringify(err.message)) + return cb(err) + } + setTimeout(cb, 3000) // include a delay to allow the request to be processed + }) + } else { + console.log('2. dry run, would start resync for', projectId) + cb() + } + } + + function forceFlush(cb) { + if (force) { + console.log('3. forcing a flush for', projectId) + UpdatesProcessor.processUpdatesForProject(projectId, err => { + console.log('err', err) + return cb(err) + }) + } else { + console.log('3. dry run, would force a flush for', projectId) + cb() + } + } + + function watchRedisQueue(cb) { + const key = Keys.projectHistoryOps({ project_id: projectId }) + function checkQueueEmpty(_callback) { + rclient.llen(key, (err, result) => { + console.log('LLEN', projectId, err, result) + if (err) { + _callback(err) + } + if (result === 0) { + _callback() + } else { + _callback(new Error('queue not empty')) + } + }) + } + if (force) { + console.log('4. checking redis queue key', key) + async.retry({ times: 30, interval: 1000 }, checkQueueEmpty, err => { + cb(err) + }) + } else { + console.log('4. dry run, would check redis key', key) + cb() + } + } + + function checkMongoFailureEntry(cb) { + if (force) { + console.log('5. checking key in mongo projectHistoryFailures', projectId) + db.projectHistoryFailures.findOne( + { project_id: projectId }, + { projection: { _id: 1 } }, + (err, result) => { + console.log('got result', err, result) + if (err) { + return cb(err) + } + if (result) { + return cb(new Error('failure record still exists')) + } + return cb() + } + ) + } else { + console.log('5. would check failure record for', projectId, 'in mongo') + cb() + } + } + + // do the checks and deletions + async.waterfall( + [ + checkNotV1Project, + startResync, + forceFlush, + watchRedisQueue, + checkMongoFailureEntry, + ], + err => { + if (!err) { + return setTimeout(callback, 1000) // include a 1 second delay + } else if (err.message === 'project not found in mongo') { + projectNotFoundErrors++ + projectsNotFound.push(projectId) + return callback() + } else if (err.message === 'no history id') { + projectNoHistoryIdErrors++ + return callback() + } else if ( + err.message === 'project is imported from v1 - will not resync it' + ) { + projectImportedFromV1Errors++ + projectsImportedFromV1.push(projectId) + return callback() + } else if ( + err.message === 'history store a non-success status code: 422' + ) { + projectsFailedErrors++ + projectsFailed.push(projectId) + return callback() + } else if (err.message === 'sync ongoing') { + projectsBrokenSyncErrors++ + projectsBrokenSync.push(projectId) + return callback() + } else { + console.log('error:', err) + return callback() + } + } + ) +} + +// find all the broken projects from the failure records +const errorsToResync = [ + 'Error: history store a non-success status code: 422', + 'OpsOutOfOrderError: project structure version out of order', +] + +async function main() { + const results = await db.projectHistoryFailures + .find({ error: { $in: errorsToResync } }) + .toArray() + + console.log('number of queues without history store 442 =', results.length) + // now check if the project is truly deleted in mongo + async.eachSeries(results.slice(0, limit), checkAndClear, err => { + console.log('Final error status', err) + console.log( + 'Project flush failed again errors', + projectsFailedErrors, + projectsFailed + ) + console.log( + 'Project flush ongoing errors', + projectsBrokenSyncErrors, + projectsBrokenSync + ) + console.log( + 'Project not found errors', + projectNotFoundErrors, + projectsNotFound + ) + console.log('Project without history_id errors', projectNoHistoryIdErrors) + console.log( + 'Project imported from V1 errors', + projectImportedFromV1Errors, + projectsImportedFromV1 + ) + process.exit() + }) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/services/project-history/test/acceptance/fixtures/blobs/35c9bd86574d61dcadbce2fdd3d4a0684272c6ea b/services/project-history/test/acceptance/fixtures/blobs/35c9bd86574d61dcadbce2fdd3d4a0684272c6ea new file mode 100644 index 0000000000..35c9bd8657 --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/blobs/35c9bd86574d61dcadbce2fdd3d4a0684272c6ea @@ -0,0 +1,404 @@ +% Choose pra, prb, prc, prd, pre, prl, prstab, or rmp for journal +% Add 'draft' option to mark overfull boxes with black boxes +% Add 'showpacs' option to make PACS codes appear +% for review and submission +%\documentclass[aps,preprint,showpacs,superscriptaddress,groupedaddress]{revtex4} % for double-spaced preprint +% needed for figures +% needed for some tables +% for math +% for math +% for crossing out text +% for coloring text +%\input{tcilatex} + + +\documentclass[aps,prl,twocolumn,showpacs,superscriptaddress,groupedaddress]{revtex4} + +\usepackage{graphicx} +\usepackage{dcolumn} +\usepackage{bm} +\usepackage{amssymb} +\usepackage{soul} +\usepackage{color} + +%TCIDATA{OutputFilter=LATEX.DLL} +%TCIDATA{Version=5.50.0.2960} +%TCIDATA{} +%TCIDATA{BibliographyScheme=BibTeX} +%TCIDATA{LastRevised=Tuesday, May 20, 2014 03:06:00} +%TCIDATA{} + +\hyphenation{ALPGEN} +\hyphenation{EVTGEN} +\hyphenation{PYTHIA} +\def\be{\begin{equation}} +\def\ee{\end{equation}} +\def\bea{\begin{eqnarray}} +\def\eea{\end{eqnarray}} +%\input{tcilatex} + +\begin{document} + +\title{Transport measurements of the spin wave gap of Mn} +\input author_list.tex +\date{\today} + +\begin{abstract} +Temperature dependent transport measurements on ultrathin antiferromagnetic +Mn films reveal a heretofore unknown non-universal weak localization +correction to the conductivity which extends to disorder strengths greater than +100~k$\Omega$ per square. The inelastic scattering of electrons off of +gapped antiferromagnetic spin waves gives rise to an inelastic scattering +length which is short enough to place the system in the 3D regime. The +extracted fitting parameters provide estimates of the energy gap ($\Delta +\approx$~16~K) and exchange energy ($\bar{J} \approx$~320~K). %\st{which are in +%agreement with values obtained with other techniques}. +\end{abstract} + +\pacs{75} + +\maketitle + +Hello world + + + +Thin-film transition metal ferromagnets (Fe, Co, Ni, Gd) and +antiferromagnets (Mn, Cr) and their alloys are not only ubiquitous in +present day technologies but are also expected to play an important role in +future developments~\cite{thompson_2008}. Understanding magnetism in these +materials, especially when the films are thin enough so that disorder plays +an important role, is complicated by the long standing controversy about the +relative importance of itinerant and local moments~\cite% +{slater_1936,van_vleck_1953,aharoni_2000}. For the itinerant transition +metal magnets, a related fundamental issue centers on the question of how +itinerancy is compromised by disorder. Clearly with sufficient disorder the +charge carriers become localized, but questions arise as to what happens to +the spins and associated spin waves and whether the outcome depends on the +ferro/antiferro alignment of spins in the itinerant parent. Ferromagnets +which have magnetization as the order parameter are fundamentally different +than antiferromagnets which have staggered magnetization (i.e., difference +between the magnetization on each sublattice) as the order parameter~\cite% +{blundell_2001}. Ferromagnetism thus distinguishes itself by having soft +modes at zero wave number whereas antiferromagnets have soft modes at finite +wave number~\cite{belitz_2005}. Accordingly, the respective spin wave +spectrums are radically different. These distinctions are particularly +important when comparing quantum corrections to the conductivity near +quantum critical points for ferromagnets~\cite{paul_2005} and +antiferromagnets~\cite{syzranov_2012}. + +Surprisingly, although there have been systematic studies of the effect of +disorder on the longitudinal $\sigma_{xx}$ and transverse $\sigma_{xy}$ +conductivity of ferromagnetic films~\cite% +{bergmann_1978,bergmann_1991,mitra_2007,misra_2009,kurzweil_2009}, there +have been few if any such studies on antiferromagnetic films. In this paper +we remedy this situation by presenting transport data on systematically +disordered Mn films that are sputter deposited in a custom designed vacuum +chamber and then transferred without exposure to air into an adjacent +cryostat for transport studies to low temperature. The experimental +procedures are similar to those reported previously: disorder, characterized +by the sheet resistance $R_0$ measured at $T=$~5~K, can be changed either by +growing separate samples or by gentle annealing of a given sample through +incremental stages of disorder~\cite{misra_2011}. Using these same procedures our results for +antiferromagnets however are decidedly different. The data are well +described over a large range of disorder strengths by a non-universal three +dimensional (3d) quantum correction that applies only to spin wave gapped +antiferromagnets. This finding implies the presence of strong inelastic +electron scattering off of antiferromagnetic spin waves. The theory is +validated not only by good fits to the data but also by extraction from the +fitting parameters of a value for the spin wave gap $\Delta$ that is in +agreement with the value expected for Mn. On the other hand, the +exchange energy $\bar{J}$ could be sensitive to the high disorder in our +ultra thin films, and it turns out to be much smaller compared to the known values. + +In previous work the inelastic scattering of electrons off of spin waves has +been an essential ingredient in understanding disordered ferromagnets. For +example, to explain the occurrence of weak-localization corrections to the +anomalous Hall effect in polycrystalline Fe films~\cite{mitra_2007}, it was +necessary to invoke a contribution to the inelastic phase breaking rate $% +\tau_{\varphi}^{-1}$ due to spin-conserving inelastic scattering off +spin-wave excitations. This phase breaking rate, anticipated by theory~\cite% +{tatara_2004} and seen experimentally in spin polarized electron energy loss +spectroscopy (SPEELS) measurements of ultrathin Fe films~\cite% +{plihal_1999,zhang_2010}, is linear in temperature and significantly larger +than the phase breaking rate due to electron-electron interactions, thus +allowing a wide temperature range to observe weak localization corrections~% +\cite{mitra_2007}. The effect of a high $\tau_{\varphi}^{-1}$ due to +inelastic scattering off spin-wave excitations is also seen in Gd films +where in addition to a localizing log($T$) quantum correction to the +conductance, a localizing linear-in-$T$ quantum correction is present and is +interpreted as a spin-wave mediated Altshuler-Aronov type correction to the +conductivity~\cite{misra_2009}. + +Interestingly, this high rate of inelastic spin rate scattering becomes even +more important for the thinnest films as shown in theoretical calculations +on Fe and Ni which point to extremely short spin-dependent inelastic mean +free paths~\cite{hong_2000} and in spin-polarized electron energy-loss +spectroscopy (SPEELS) measurements on few monolayer-thick Fe/W(110) films in +which a strong nonmonotonic enhancement of localized spin wave energies is +found on the thinnest films~\cite{zhang_2010}. + +Inelastic spin wave scattering in highly disordered ferromagnetic films can +be strong enough to assure that the associated $T$-dependent dephasing +length $L_{\varphi }(T)=\sqrt{D\tau _{\varphi }}$ (with $D$ the diffusion +constant)~\cite{lee_1985} is less than the film thickness $t$, thus putting +thin films into the 3d limit where a metal-insulator +transition is observed~\cite{misra_2011}. Recognizing that similarly high +inelastic scattering rates must apply to highly disordered antiferromagnetic +films, we first proceed with a theoretical approach that takes into account +the scattering of antiferromagnetic spin waves on the phase relaxation rate +and find a heretofore unrecognized non-universal 3d weak localization +correction to the conductivity that allows an interpretation of our experimental +results. + +We mention in passing that the 3d interaction-induced quantum correction +found to be dominant in the case of ferromagnetic Gd +films which undergo a metal-insulator transition\cite{misra_2011} is +found to be much smaller in the present case and will not be considered further (for an estimate of this contribution see \cite{muttalib_unpub}. + +As discussed in detail in Ref.~[\onlinecite{wm10}], the phase relaxation +time $\tau _{\varphi }$ limits the phase coherence in a particle-particle +diffusion propagator $C(q,\omega )$ (Cooperon) in the form +\begin{equation} +C(q,\omega _{l})=\frac{1}{2\pi N_{0}\tau ^{2}}\frac{1}{Dq^{2}+|\omega +_{l}|+1/\tau _{\varphi }}. +\end{equation} +where $N_{0}$ is the density of states at the Fermi level, $\tau $ is the +elastic scattering time and $\omega _{l}=2\pi lT$ is the Matsubara +frequency. Labeling the Cooperon propagator in the absence of interactions +as $C_{0}$, we can write +\begin{equation} +\frac{1}{\tau _{\varphi }}=\frac{1}{2\pi N_{0}\tau ^{2}}[C^{-1}-C_{0}^{-1}]. +\end{equation} + +In general, $C(q,\omega )$ can be evaluated diagrammatically in the presence +of interactions and disorder in a ladder approximation \cite{fa} that can be +symbolically written as $C=C_{0}+C_{0}KC$ where the interaction vertex $K$ +contains self energy as well as vertex corrections due to both interactions +and disorder. It then follows that $1/\tau _{\varphi }$ is given by +\begin{equation} +\frac{1}{\tau _{\varphi }}=-\frac{1}{2\pi N_{0}\tau ^{2}}K. +\end{equation}% +In Ref.~[\onlinecite{wm10}], the leading temperature and disorder dependence +of the inelastic diffusion propagator was evaluated diagrammatically, in the +presence of ferromagnetic spin-wave mediated electron-electron interactions. +Here we consider the antiferromagnetic case. We only consider large +spin-wave gap where the damping can be ignored. Using the antiferromagnetic +dispersion relation $\omega _{q}=\Delta +Aq$, where $A$ is the spin +stiffness, the inelastic lifetime is given by +\be +\frac{\hbar }{\tau _{\varphi }}=\frac{4}{\pi \hbar }nJ^{2}\int_{0}^{1/l}% +\frac{q^{d-1}dq}{\sinh \beta \omega _{q}}\frac{Dq^{2}+1/\tau _{\varphi }}{% +(Dq^{2}+1/\tau _{\varphi })^{2}+\omega _{q}^{2}} +\ee% +where $n=k_{F}^{3}/3\pi ^{2}$ is the 3d density, $J$ is the effective +spin-exchange interaction and $\beta =1/k_{B}T$. Here we will consider the +limit $\hbar /\tau _{\varphi }\ll \Delta $, relevant for our experiment on +Mn. In this limit we can neglect the $1/\tau _{\varphi }$ terms inside the +integral. The upper limit should be restricted to $\Delta /A$ in the limit $% +\Delta /A<1/l$. For large disorder, we expect the parameter $x\equiv +\hbar Dk_{F}^{2}\Delta / \bar{J}^{2}\ll 1$, where the spin-exchange energy +is given by $\bar{J}=Ak_{F}$. In this limit, $L_{\varphi }$ can be +simplified as +\be +k_{F}L_{\varphi }\approx \left( \frac{\bar{J}}{\Delta }\right) ^{3/2}\left( +\frac{5\sinh \frac{\Delta }{T}}{12\pi }\right) ^{1/2},\;\;\;x\ll 1 +\label{L-phi-3d} +\ee% +which is independent of $x$, and therefore, independent of disorder. + +Given the inelastic lifetime, the weak localization correction in 3d is +usually given by \cite{lee_1985} $\delta \sigma _{3d}=\frac{e^{2}}{\hbar \pi +^{3}}\frac{1}{L_{\varphi }},$ where the prefactor to the inverse inelastic +length is a universal number, independent of disorder. However, at large +enough disorder, we show that there exists a disorder dependent correction, +due to the scale dependent diffusion coefficient near the Anderson +metal-insulator transition. In fact, the diffusion coefficient obeys the +self consistent equation \cite{WV} +\begin{equation} +\frac{D_{0}}{D(\omega )}=1+\frac{k_{F}^{2-d}}{\pi m}\int_{0}^{1/l}dQ\frac{% +Q^{d-1}}{-i\omega +D(\omega )Q^{2}} +\end{equation}% +where $D_{0}=v_{F}l/d$ is the diffusion coefficient at weak disorder. While +the significance of the prefactor to the integral is not clear, the above +equation remains qualitatively accurate over a wide range near the Anderson +transition. Setting $\omega =i/\tau _{\varphi }$ and doing the $Q$-integral +in 3d, +\bea +\frac{D_{0}}{D} &\approx & 1+\frac{1}{\pi mk_{F}}\int_{1/L_{\phi }}^{1/l}dQ\frac{% +Q^{2}}{DQ^{2}}\cr +&=& 1+\frac{D_{0}}{D}\frac{3}{\pi k_{F}^{2}l^{2}}-\delta +\left( \frac{D_{0}}{D}\right) , +\label{delta} +\eea% +where +\bea +\delta \equiv \frac{D_{0}}{D}\frac{3}{\pi k_{F}^{2}l^{2}}\frac{l}{% +L_{\varphi }} +\eea +is assumed to be a small correction, and Eq.~(\ref{delta}) +should not be solved self-consistently. This follows from the fact that the +diffusion coefficient of electrons at fixed energy entering the Cooperon +expression is that of non-interacting electrons, and is given by the limit $% +T\rightarrow 0$, $L_{\varphi }\rightarrow \infty $ and therefore $\delta +\rightarrow 0$. Then the correction at finite $T$ is given by +\bea +\frac{D}{D_{0}} &=& \frac{1}{\left( \frac{D_{0}}{D}\right) _{0}-\delta \left( +\frac{D_{0}}{D}\right) }\cr +&\approx & \left( \frac{D}{D_{0}}\right) _{0}+\left( \frac{D}{D_{0}}\right) _{0} +\frac{3}{\pi k_{F}^{2}l^{2}}\frac{l}{L_{\varphi }}% +\eea% +where +\be +\lim_{T\rightarrow 0}\frac{D}{D_{0}}\equiv \left( \frac{D}{D_{0}}\right) +_{0}. +\ee% +Using the relation $\sigma _{3d}=(e^{2}/\hbar )nD$ where the longitudinal +sheet conductance $\sigma _{\square }=\sigma _{3d}t$, with $t$ being the +film thickness, we finally get the temperature dependent weak localization +correction term +\bea +\frac{\delta \sigma _{\square }}{L_{00}} &=& \left( \frac{D}{D_{0}}\right) _{0}% +\frac{2}{\pi }\frac{t}{L_{\varphi }}\cr +\left( \frac{D}{D_{0}}\right)_{0} &\approx &\frac{2}{1+\sqrt{1+\frac{4R_{0}^{2}}{a^{2}}}} +\label{WL} +\eea% +where $R_{0}=L_{00}/\sigma _{\square }(T$=$0)$, $L_{00}=e^{2}/\pi h$, $% +a=3\pi/2k_{F}tb_{0}$, $b_{0}$ is a number of order unity and we +have solved the self-consistent equation for $D$ in order to express $D_{0% +\text{ }}$in terms of $D$ and finally $R_{0}$. Thus in this case, the weak +localization correction has a prefactor which is not universal. While this +reduces to the well-known universal result at weak disorder $R_{0}\ll a$, it +becomes dependent on disorder characterized by the sheet resistance $R_{0}$ +at strong disorder and at the same time substantially extends the 3d regime +near the transition. + +Using the expression for $L_{\varphi }$ (Eq.~(\ref{L-phi-3d})) into Eq.~(\ref% +{WL}), we finally obtain the total conductivity, including the quantum +correction to the conductivity due to weak localization in 3d arising from +scattering of electrons off antiferromagnetic spin waves in Mn, +\begin{equation} +\frac{\sigma _{\square }}{L_{00}}=A+\frac{B}{\sqrt{\sinh [\Delta /T]}}, +\label{sigmaWL} +\end{equation}% +\textbf{\textbf{}}where the parameter $A$ is temperature independent and the parameter +\bea +B &\equiv & \left( \frac{D}{D_{0}}\right) _{0}\frac{2}{\pi ^{2}}\left( \frac{% +12\pi }{5}\right) ^{1/2}\left( \frac{\Delta }{\bar{J}}\right) ^{3/2}tk_{F}\cr% +&=&\frac{2c}{1+\sqrt{1+\frac{4R_{0}^{2}}{a^{2}}}}, +\label{BFit} +\eea% +where +\be +c\equiv \left( \frac{\Delta }{\bar{J}}\right) ^{3/2}\left( \frac{% +48t^{2}k_{F}^{2}}{5\pi}\right) ^{1/2}. +\label{cFit} +\ee + +The data presented here is for a single film prepared with an initial $R_0 +\approx$~6~k$\Omega$. Disorder was consequently increased in incremental +stages up to 180~k$\Omega$ by annealing at approximately 280~K~\cite% +{misra_2011}. Additional samples were grown at intermediate disorder and +measured to check reproducibility. + +Figure~\ref{fig:cond} shows the conductivity data for two samples with +disorder $R_{0}=$~17573~$\Omega $ and 63903~$\Omega $ with corresponding +fittings to the expression (\ref{sigmaWL}) where $A$ and $B$ are taken as +fitting parameters and $\Delta =$~16~K is the spin wave gap. The fits are +sensitive to the parameters $A$ and $B$ but relatively insensitive to $% +\Delta $. We find that $\Delta =$~16~$\pm $~4~K provides good fittings in +the whole range of disorder (from 6 to 180~k$\Omega $). + +\begin{figure}[tbp] +\begin{center} +\includegraphics[width=9cm]{fig_1_16.eps} +\end{center} +\caption{The temperature-dependent normalized conductivity (open squares) +for two samples with the indicated disorder strengths of $R_0 =$~17573~$% +\Omega$ and 63903~$\Omega$ show good agreement with theory (solid lines). +The fitting parameters $A$ and $B$ are indicated for each curve with the +error in the least significant digit indicated in parentheses.} +\label{fig:cond} +\end{figure} + +Figure~\ref{fig:parb} shows the dependence of the parameter $B$ on the +disorder strength $R_0$ (open squares) and a theoretical fit (solid line) +using Eq.~(\ref{BFit}), where $c$ and $a$ are fitting parameters. The solid +line for this two-paramener fit is drawn for the best-fit values $c=0.67 \pm +0.04$ and $a= 28 \pm 3$~k$\Omega$. We note that the fit is of reasonable +quality over most of the disorder range except for the film with the least +disorder ($R_0 = 6$~k$\Omega$) where $B = 0.77$, +somewhat below the saturated value +$B = c = 0.67$ evaluated from Eq.~(\ref{BFit}) at $R_0 = 0$. Using higher +values of $c$ (e.g., $c=0.8$) and lower values of $a$ (eg., $a = 22$~k$\Omega$) +improves the fit at low disorder strengths but +increases the discrepancy at higher disorder strengths. + +%L_phi/t = 2/pi*2/(1+sqrt(1+16))/0.5, 2/pi*2/(1+sqrt(1+1))/0.25 + +%http://hyperphysics.phy-astr.gsu.edu/hbase/tables/fermi.html , k_F = sqrt(2*m_e*(10.9 eV))/(hbar) = 1.7E10 1/m + +% (bar(J) / \Delta) ^ 3/2 = (48*(2e-9)^2*(2.7e9)^2/5/pi/(0.65)^2) ^0.5 = 8360 = 20 ^ 3 +%A = \bar{J} / k_F , \bar{J} = nJ + +Substituting the Fermi energy for bulk Mn~\cite{ashcroft_1976}, +a thickness $t=2$~nm known to 20\% accuracy, together with the best-fit +value for $c$ into Eq.~(\ref{cFit}), we calculate the value $\bar{J} =$~320~$% +\pm$~93~K. Gao et al.~\cite{gao_2008} performed inelastic scanning tunneling +spectroscopy (ISTS) on thin Mn films and reported $\Delta$ in the range from +30 to 60~K and $\bar{J}=vk_F=$~3150~$\pm$~200~K. The agreement of energy gaps is +good; however our significantly lower value of $\bar{J}$ is probably due to the +high disorder in our ultra thin films. + +Since the temperature-dependent correction $B/\sqrt{\sinh (\Delta /T)}$ of +Eq.~\ref{sigmaWL} is small compared to the parameter $A$, we can write +$\sigma_{\square} \approx 1/R_0$ so that Eq.~\ref{sigmaWL} reduces to the +expression $A \approx 1/L_{00}R_0$. The logarithmic plot derived by taking the +logarithm of both sides of this approximation is shown in the inset of +Fig.~\ref{fig:parb}. The slope of -1 confirms the linear dependence of $A$ on +$1/R_0$ and the intercept of 5.01 (10$^{5.01}\approx $~102~k$\Omega$) is +within 20\% of the expected theoretical value $L_{00}=$~81~k$\Omega $, +for the normalization constant. Accordingly, the conductivity corrections in +Eq.~\ref{sigmaWL} are small compared to the zero temperature conductivity and +the normalization constant $L_{00}$ for the conductivity is close to the +expected theoretical value. + +Using Eq.~(\ref{WL}) and the obtained value for $a\approx $~28~k$\Omega $ we can +compare the dephasing length ($L_{\varphi }$) with the thickness ($t\approx $% +~2~nm) at 16~K. For the sample with $R_{0}=$~63903~$\Omega $ the ratio $% +L_{\varphi }/t\approx $~0.5 and for the sample with $R_{0}=$~17573~$\Omega $ +$L_{\varphi }/t\approx $~2. The latter estimate assumes no spin +polarization, while a full polarization would imply $L_{\varphi }/t\approx $% +~1. Thus $L_{\varphi }$ is smaller than or close to the thickness of the +film, which keeps the film in the three-dimensional regime for almost all +temperatures and disorder strengths considered. + +\begin{figure}[tbp] +\begin{center} +\includegraphics[width=9cm]{fig_2_16.eps} +\end{center} +\caption{Dependence of the fitting parameters $B$ and $A$ (inset) on +disorder $R_0$ for $\Delta=$~16~K. The fitting parameters are indicated for +each curve with the error in the least significant digit indicated in +parentheses.} +\label{fig:parb} +\end{figure} + +In conclusion, we have performed \textit{in situ} transport measurements on +ultra thin Mn films, systematically varying the disorder ($R_{0}=R_{xx}$($T=$% +~5~K)). The obtained data were analyzed within a weak localization theory in +3d generalized to strong disorder. In the temperature range considered +inelastic scattering off spin waves is found to be strong giving rise to a +dephasing length shorter than the film thickness, which places these systems +into the 3d regime. The obtained value for the spin wave gap was close to +the one measured by Gao et al.~\cite{gao_2008} using ISTS, while the +exchange energy was much smaller. + +This work has been supported by the NSF under Grant No 1305783 (AFH). +PW thanks A.\ M.\ \ Finkel'stein for useful discussions and acknowledges +partial support through the DFG research unit "Quantum phase transitions". + +\bibliographystyle{apsrev} +\bibliography{bibl} + +\end{document} diff --git a/services/project-history/test/acceptance/fixtures/blobs/4f785a4c192155b240e3042b3a7388b47603f423 b/services/project-history/test/acceptance/fixtures/blobs/4f785a4c192155b240e3042b3a7388b47603f423 new file mode 100644 index 0000000000..4f785a4c19 --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/blobs/4f785a4c192155b240e3042b3a7388b47603f423 @@ -0,0 +1,3 @@ +Hello world + +One two three \ No newline at end of file diff --git a/services/project-history/test/acceptance/fixtures/blobs/c6654ea913979e13e22022653d284444f284a172 b/services/project-history/test/acceptance/fixtures/blobs/c6654ea913979e13e22022653d284444f284a172 new file mode 100644 index 0000000000..c6654ea913 --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/blobs/c6654ea913979e13e22022653d284444f284a172 @@ -0,0 +1,5 @@ +Hello world + +One two three + +Four five six \ No newline at end of file diff --git a/services/project-history/test/acceptance/fixtures/blobs/e13c315d53aaef3aa34550a86b09cff091ace220 b/services/project-history/test/acceptance/fixtures/blobs/e13c315d53aaef3aa34550a86b09cff091ace220 new file mode 100644 index 0000000000..e13c315d53 --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/blobs/e13c315d53aaef3aa34550a86b09cff091ace220 @@ -0,0 +1,7 @@ +Hello world + +One two three + +Four five six + +Seven eight nine \ No newline at end of file diff --git a/services/project-history/test/acceptance/fixtures/blobs/f28571f561d198b87c24cc6a98b78e87b665e22d b/services/project-history/test/acceptance/fixtures/blobs/f28571f561d198b87c24cc6a98b78e87b665e22d new file mode 100644 index 0000000000..f28571f561 --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/blobs/f28571f561d198b87c24cc6a98b78e87b665e22d @@ -0,0 +1,404 @@ +% Choose pra, prb, prc, prd, pre, prl, prstab, or rmp for journal +% Add 'draft' option to mark overfull boxes with black boxes +% Add 'showpacs' option to make PACS codes appear +% for review and submission +%\documentclass[aps,preprint,showpacs,superscriptaddress,groupedaddress]{revtex4} % for double-spaced preprint +% needed for figures +% needed for some tables +% for math +% for math +% for crossing out text +% for coloring text +%\input{tcilatex} + + +\documentclass[aps,prl,twocolumn,showpacs,superscriptaddress,groupedaddress]{revtex4} + +\usepackage{graphicx} +\usepackage{dcolumn} +\usepackage{bm} +\usepackage{amssymb} +\usepackage{soul} +\usepackage{color} + +%TCIDATA{OutputFilter=LATEX.DLL} +%TCIDATA{Version=5.50.0.2960} +%TCIDATA{} +%TCIDATA{BibliographyScheme=BibTeX} +%TCIDATA{LastRevised=Tuesday, May 20, 2014 03:06:00} +%TCIDATA{} + +\hyphenation{ALPGEN} +\hyphenation{EVTGEN} +\hyphenation{PYTHIA} +\def\be{\begin{equation}} +\def\ee{\end{equation}} +\def\bea{\begin{eqnarray}} +\def\eea{\end{eqnarray}} +%\input{tcilatex} + +\begin{document} + +\title{Transport measurements of the spin wave gap of Mn} +\input author_list.tex +\date{\today} + +\begin{abstract} +Temperature dependent transport measurements on ultrathin antiferromagnetic +Mn films reveal a heretofore unknown non-universal weak localization +correction to the conductivity which extends to disorder strengths greater than +100~k$\Omega$ per square. The inelastic scattering of electrons off of +gapped antiferromagnetic spin waves gives rise to an inelastic scattering +length which is short enough to place the system in the 3D regime. The +extracted fitting parameters provide estimates of the energy gap ($\Delta +\approx$~16~K) and exchange energy ($\bar{J} \approx$~320~K). %\st{which are in +%agreement with values obtained with other techniques}. +\end{abstract} + +\pacs{75} + +\maketitle + + + + + +Thin-film transition metal ferromagnets (Fe, Co, Ni, Gd) and +antiferromagnets (Mn, Cr) and their alloys are not only ubiquitous in +present day technologies but are also expected to play an important role in +future developments~\cite{thompson_2008}. Understanding magnetism in these +materials, especially when the films are thin enough so that disorder plays +an important role, is complicated by the long standing controversy about the +relative importance of itinerant and local moments~\cite% +{slater_1936,van_vleck_1953,aharoni_2000}. For the itinerant transition +metal magnets, a related fundamental issue centers on the question of how +itinerancy is compromised by disorder. Clearly with sufficient disorder the +charge carriers become localized, but questions arise as to what happens to +the spins and associated spin waves and whether the outcome depends on the +ferro/antiferro alignment of spins in the itinerant parent. Ferromagnets +which have magnetization as the order parameter are fundamentally different +than antiferromagnets which have staggered magnetization (i.e., difference +between the magnetization on each sublattice) as the order parameter~\cite% +{blundell_2001}. Ferromagnetism thus distinguishes itself by having soft +modes at zero wave number whereas antiferromagnets have soft modes at finite +wave number~\cite{belitz_2005}. Accordingly, the respective spin wave +spectrums are radically different. These distinctions are particularly +important when comparing quantum corrections to the conductivity near +quantum critical points for ferromagnets~\cite{paul_2005} and +antiferromagnets~\cite{syzranov_2012}. + +Surprisingly, although there have been systematic studies of the effect of +disorder on the longitudinal $\sigma_{xx}$ and transverse $\sigma_{xy}$ +conductivity of ferromagnetic films~\cite% +{bergmann_1978,bergmann_1991,mitra_2007,misra_2009,kurzweil_2009}, there +have been few if any such studies on antiferromagnetic films. In this paper +we remedy this situation by presenting transport data on systematically +disordered Mn films that are sputter deposited in a custom designed vacuum +chamber and then transferred without exposure to air into an adjacent +cryostat for transport studies to low temperature. The experimental +procedures are similar to those reported previously: disorder, characterized +by the sheet resistance $R_0$ measured at $T=$~5~K, can be changed either by +growing separate samples or by gentle annealing of a given sample through +incremental stages of disorder~\cite{misra_2011}. Using these same procedures our results for +antiferromagnets however are decidedly different. The data are well +described over a large range of disorder strengths by a non-universal three +dimensional (3d) quantum correction that applies only to spin wave gapped +antiferromagnets. This finding implies the presence of strong inelastic +electron scattering off of antiferromagnetic spin waves. The theory is +validated not only by good fits to the data but also by extraction from the +fitting parameters of a value for the spin wave gap $\Delta$ that is in +agreement with the value expected for Mn. On the other hand, the +exchange energy $\bar{J}$ could be sensitive to the high disorder in our +ultra thin films, and it turns out to be much smaller compared to the known values. + +In previous work the inelastic scattering of electrons off of spin waves has +been an essential ingredient in understanding disordered ferromagnets. For +example, to explain the occurrence of weak-localization corrections to the +anomalous Hall effect in polycrystalline Fe films~\cite{mitra_2007}, it was +necessary to invoke a contribution to the inelastic phase breaking rate $% +\tau_{\varphi}^{-1}$ due to spin-conserving inelastic scattering off +spin-wave excitations. This phase breaking rate, anticipated by theory~\cite% +{tatara_2004} and seen experimentally in spin polarized electron energy loss +spectroscopy (SPEELS) measurements of ultrathin Fe films~\cite% +{plihal_1999,zhang_2010}, is linear in temperature and significantly larger +than the phase breaking rate due to electron-electron interactions, thus +allowing a wide temperature range to observe weak localization corrections~% +\cite{mitra_2007}. The effect of a high $\tau_{\varphi}^{-1}$ due to +inelastic scattering off spin-wave excitations is also seen in Gd films +where in addition to a localizing log($T$) quantum correction to the +conductance, a localizing linear-in-$T$ quantum correction is present and is +interpreted as a spin-wave mediated Altshuler-Aronov type correction to the +conductivity~\cite{misra_2009}. + +Interestingly, this high rate of inelastic spin rate scattering becomes even +more important for the thinnest films as shown in theoretical calculations +on Fe and Ni which point to extremely short spin-dependent inelastic mean +free paths~\cite{hong_2000} and in spin-polarized electron energy-loss +spectroscopy (SPEELS) measurements on few monolayer-thick Fe/W(110) films in +which a strong nonmonotonic enhancement of localized spin wave energies is +found on the thinnest films~\cite{zhang_2010}. + +Inelastic spin wave scattering in highly disordered ferromagnetic films can +be strong enough to assure that the associated $T$-dependent dephasing +length $L_{\varphi }(T)=\sqrt{D\tau _{\varphi }}$ (with $D$ the diffusion +constant)~\cite{lee_1985} is less than the film thickness $t$, thus putting +thin films into the 3d limit where a metal-insulator +transition is observed~\cite{misra_2011}. Recognizing that similarly high +inelastic scattering rates must apply to highly disordered antiferromagnetic +films, we first proceed with a theoretical approach that takes into account +the scattering of antiferromagnetic spin waves on the phase relaxation rate +and find a heretofore unrecognized non-universal 3d weak localization +correction to the conductivity that allows an interpretation of our experimental +results. + +We mention in passing that the 3d interaction-induced quantum correction +found to be dominant in the case of ferromagnetic Gd +films which undergo a metal-insulator transition\cite{misra_2011} is +found to be much smaller in the present case and will not be considered further (for an estimate of this contribution see \cite{muttalib_unpub}. + +As discussed in detail in Ref.~[\onlinecite{wm10}], the phase relaxation +time $\tau _{\varphi }$ limits the phase coherence in a particle-particle +diffusion propagator $C(q,\omega )$ (Cooperon) in the form +\begin{equation} +C(q,\omega _{l})=\frac{1}{2\pi N_{0}\tau ^{2}}\frac{1}{Dq^{2}+|\omega +_{l}|+1/\tau _{\varphi }}. +\end{equation} +where $N_{0}$ is the density of states at the Fermi level, $\tau $ is the +elastic scattering time and $\omega _{l}=2\pi lT$ is the Matsubara +frequency. Labeling the Cooperon propagator in the absence of interactions +as $C_{0}$, we can write +\begin{equation} +\frac{1}{\tau _{\varphi }}=\frac{1}{2\pi N_{0}\tau ^{2}}[C^{-1}-C_{0}^{-1}]. +\end{equation} + +In general, $C(q,\omega )$ can be evaluated diagrammatically in the presence +of interactions and disorder in a ladder approximation \cite{fa} that can be +symbolically written as $C=C_{0}+C_{0}KC$ where the interaction vertex $K$ +contains self energy as well as vertex corrections due to both interactions +and disorder. It then follows that $1/\tau _{\varphi }$ is given by +\begin{equation} +\frac{1}{\tau _{\varphi }}=-\frac{1}{2\pi N_{0}\tau ^{2}}K. +\end{equation}% +In Ref.~[\onlinecite{wm10}], the leading temperature and disorder dependence +of the inelastic diffusion propagator was evaluated diagrammatically, in the +presence of ferromagnetic spin-wave mediated electron-electron interactions. +Here we consider the antiferromagnetic case. We only consider large +spin-wave gap where the damping can be ignored. Using the antiferromagnetic +dispersion relation $\omega _{q}=\Delta +Aq$, where $A$ is the spin +stiffness, the inelastic lifetime is given by +\be +\frac{\hbar }{\tau _{\varphi }}=\frac{4}{\pi \hbar }nJ^{2}\int_{0}^{1/l}% +\frac{q^{d-1}dq}{\sinh \beta \omega _{q}}\frac{Dq^{2}+1/\tau _{\varphi }}{% +(Dq^{2}+1/\tau _{\varphi })^{2}+\omega _{q}^{2}} +\ee% +where $n=k_{F}^{3}/3\pi ^{2}$ is the 3d density, $J$ is the effective +spin-exchange interaction and $\beta =1/k_{B}T$. Here we will consider the +limit $\hbar /\tau _{\varphi }\ll \Delta $, relevant for our experiment on +Mn. In this limit we can neglect the $1/\tau _{\varphi }$ terms inside the +integral. The upper limit should be restricted to $\Delta /A$ in the limit $% +\Delta /A<1/l$. For large disorder, we expect the parameter $x\equiv +\hbar Dk_{F}^{2}\Delta / \bar{J}^{2}\ll 1$, where the spin-exchange energy +is given by $\bar{J}=Ak_{F}$. In this limit, $L_{\varphi }$ can be +simplified as +\be +k_{F}L_{\varphi }\approx \left( \frac{\bar{J}}{\Delta }\right) ^{3/2}\left( +\frac{5\sinh \frac{\Delta }{T}}{12\pi }\right) ^{1/2},\;\;\;x\ll 1 +\label{L-phi-3d} +\ee% +which is independent of $x$, and therefore, independent of disorder. + +Given the inelastic lifetime, the weak localization correction in 3d is +usually given by \cite{lee_1985} $\delta \sigma _{3d}=\frac{e^{2}}{\hbar \pi +^{3}}\frac{1}{L_{\varphi }},$ where the prefactor to the inverse inelastic +length is a universal number, independent of disorder. However, at large +enough disorder, we show that there exists a disorder dependent correction, +due to the scale dependent diffusion coefficient near the Anderson +metal-insulator transition. In fact, the diffusion coefficient obeys the +self consistent equation \cite{WV} +\begin{equation} +\frac{D_{0}}{D(\omega )}=1+\frac{k_{F}^{2-d}}{\pi m}\int_{0}^{1/l}dQ\frac{% +Q^{d-1}}{-i\omega +D(\omega )Q^{2}} +\end{equation}% +where $D_{0}=v_{F}l/d$ is the diffusion coefficient at weak disorder. While +the significance of the prefactor to the integral is not clear, the above +equation remains qualitatively accurate over a wide range near the Anderson +transition. Setting $\omega =i/\tau _{\varphi }$ and doing the $Q$-integral +in 3d, +\bea +\frac{D_{0}}{D} &\approx & 1+\frac{1}{\pi mk_{F}}\int_{1/L_{\phi }}^{1/l}dQ\frac{% +Q^{2}}{DQ^{2}}\cr +&=& 1+\frac{D_{0}}{D}\frac{3}{\pi k_{F}^{2}l^{2}}-\delta +\left( \frac{D_{0}}{D}\right) , +\label{delta} +\eea% +where +\bea +\delta \equiv \frac{D_{0}}{D}\frac{3}{\pi k_{F}^{2}l^{2}}\frac{l}{% +L_{\varphi }} +\eea +is assumed to be a small correction, and Eq.~(\ref{delta}) +should not be solved self-consistently. This follows from the fact that the +diffusion coefficient of electrons at fixed energy entering the Cooperon +expression is that of non-interacting electrons, and is given by the limit $% +T\rightarrow 0$, $L_{\varphi }\rightarrow \infty $ and therefore $\delta +\rightarrow 0$. Then the correction at finite $T$ is given by +\bea +\frac{D}{D_{0}} &=& \frac{1}{\left( \frac{D_{0}}{D}\right) _{0}-\delta \left( +\frac{D_{0}}{D}\right) }\cr +&\approx & \left( \frac{D}{D_{0}}\right) _{0}+\left( \frac{D}{D_{0}}\right) _{0} +\frac{3}{\pi k_{F}^{2}l^{2}}\frac{l}{L_{\varphi }}% +\eea% +where +\be +\lim_{T\rightarrow 0}\frac{D}{D_{0}}\equiv \left( \frac{D}{D_{0}}\right) +_{0}. +\ee% +Using the relation $\sigma _{3d}=(e^{2}/\hbar )nD$ where the longitudinal +sheet conductance $\sigma _{\square }=\sigma _{3d}t$, with $t$ being the +film thickness, we finally get the temperature dependent weak localization +correction term +\bea +\frac{\delta \sigma _{\square }}{L_{00}} &=& \left( \frac{D}{D_{0}}\right) _{0}% +\frac{2}{\pi }\frac{t}{L_{\varphi }}\cr +\left( \frac{D}{D_{0}}\right)_{0} &\approx &\frac{2}{1+\sqrt{1+\frac{4R_{0}^{2}}{a^{2}}}} +\label{WL} +\eea% +where $R_{0}=L_{00}/\sigma _{\square }(T$=$0)$, $L_{00}=e^{2}/\pi h$, $% +a=3\pi/2k_{F}tb_{0}$, $b_{0}$ is a number of order unity and we +have solved the self-consistent equation for $D$ in order to express $D_{0% +\text{ }}$in terms of $D$ and finally $R_{0}$. Thus in this case, the weak +localization correction has a prefactor which is not universal. While this +reduces to the well-known universal result at weak disorder $R_{0}\ll a$, it +becomes dependent on disorder characterized by the sheet resistance $R_{0}$ +at strong disorder and at the same time substantially extends the 3d regime +near the transition. + +Using the expression for $L_{\varphi }$ (Eq.~(\ref{L-phi-3d})) into Eq.~(\ref% +{WL}), we finally obtain the total conductivity, including the quantum +correction to the conductivity due to weak localization in 3d arising from +scattering of electrons off antiferromagnetic spin waves in Mn, +\begin{equation} +\frac{\sigma _{\square }}{L_{00}}=A+\frac{B}{\sqrt{\sinh [\Delta /T]}}, +\label{sigmaWL} +\end{equation}% +\textbf{\textbf{}}where the parameter $A$ is temperature independent and the parameter +\bea +B &\equiv & \left( \frac{D}{D_{0}}\right) _{0}\frac{2}{\pi ^{2}}\left( \frac{% +12\pi }{5}\right) ^{1/2}\left( \frac{\Delta }{\bar{J}}\right) ^{3/2}tk_{F}\cr% +&=&\frac{2c}{1+\sqrt{1+\frac{4R_{0}^{2}}{a^{2}}}}, +\label{BFit} +\eea% +where +\be +c\equiv \left( \frac{\Delta }{\bar{J}}\right) ^{3/2}\left( \frac{% +48t^{2}k_{F}^{2}}{5\pi}\right) ^{1/2}. +\label{cFit} +\ee + +The data presented here is for a single film prepared with an initial $R_0 +\approx$~6~k$\Omega$. Disorder was consequently increased in incremental +stages up to 180~k$\Omega$ by annealing at approximately 280~K~\cite% +{misra_2011}. Additional samples were grown at intermediate disorder and +measured to check reproducibility. + +Figure~\ref{fig:cond} shows the conductivity data for two samples with +disorder $R_{0}=$~17573~$\Omega $ and 63903~$\Omega $ with corresponding +fittings to the expression (\ref{sigmaWL}) where $A$ and $B$ are taken as +fitting parameters and $\Delta =$~16~K is the spin wave gap. The fits are +sensitive to the parameters $A$ and $B$ but relatively insensitive to $% +\Delta $. We find that $\Delta =$~16~$\pm $~4~K provides good fittings in +the whole range of disorder (from 6 to 180~k$\Omega $). + +\begin{figure}[tbp] +\begin{center} +\includegraphics[width=9cm]{fig_1_16.eps} +\end{center} +\caption{The temperature-dependent normalized conductivity (open squares) +for two samples with the indicated disorder strengths of $R_0 =$~17573~$% +\Omega$ and 63903~$\Omega$ show good agreement with theory (solid lines). +The fitting parameters $A$ and $B$ are indicated for each curve with the +error in the least significant digit indicated in parentheses.} +\label{fig:cond} +\end{figure} + +Figure~\ref{fig:parb} shows the dependence of the parameter $B$ on the +disorder strength $R_0$ (open squares) and a theoretical fit (solid line) +using Eq.~(\ref{BFit}), where $c$ and $a$ are fitting parameters. The solid +line for this two-paramener fit is drawn for the best-fit values $c=0.67 \pm +0.04$ and $a= 28 \pm 3$~k$\Omega$. We note that the fit is of reasonable +quality over most of the disorder range except for the film with the least +disorder ($R_0 = 6$~k$\Omega$) where $B = 0.77$, +somewhat below the saturated value +$B = c = 0.67$ evaluated from Eq.~(\ref{BFit}) at $R_0 = 0$. Using higher +values of $c$ (e.g., $c=0.8$) and lower values of $a$ (eg., $a = 22$~k$\Omega$) +improves the fit at low disorder strengths but +increases the discrepancy at higher disorder strengths. + +%L_phi/t = 2/pi*2/(1+sqrt(1+16))/0.5, 2/pi*2/(1+sqrt(1+1))/0.25 + +%http://hyperphysics.phy-astr.gsu.edu/hbase/tables/fermi.html , k_F = sqrt(2*m_e*(10.9 eV))/(hbar) = 1.7E10 1/m + +% (bar(J) / \Delta) ^ 3/2 = (48*(2e-9)^2*(2.7e9)^2/5/pi/(0.65)^2) ^0.5 = 8360 = 20 ^ 3 +%A = \bar{J} / k_F , \bar{J} = nJ + +Substituting the Fermi energy for bulk Mn~\cite{ashcroft_1976}, +a thickness $t=2$~nm known to 20\% accuracy, together with the best-fit +value for $c$ into Eq.~(\ref{cFit}), we calculate the value $\bar{J} =$~320~$% +\pm$~93~K. Gao et al.~\cite{gao_2008} performed inelastic scanning tunneling +spectroscopy (ISTS) on thin Mn films and reported $\Delta$ in the range from +30 to 60~K and $\bar{J}=vk_F=$~3150~$\pm$~200~K. The agreement of energy gaps is +good; however our significantly lower value of $\bar{J}$ is probably due to the +high disorder in our ultra thin films. + +Since the temperature-dependent correction $B/\sqrt{\sinh (\Delta /T)}$ of +Eq.~\ref{sigmaWL} is small compared to the parameter $A$, we can write +$\sigma_{\square} \approx 1/R_0$ so that Eq.~\ref{sigmaWL} reduces to the +expression $A \approx 1/L_{00}R_0$. The logarithmic plot derived by taking the +logarithm of both sides of this approximation is shown in the inset of +Fig.~\ref{fig:parb}. The slope of -1 confirms the linear dependence of $A$ on +$1/R_0$ and the intercept of 5.01 (10$^{5.01}\approx $~102~k$\Omega$) is +within 20\% of the expected theoretical value $L_{00}=$~81~k$\Omega $, +for the normalization constant. Accordingly, the conductivity corrections in +Eq.~\ref{sigmaWL} are small compared to the zero temperature conductivity and +the normalization constant $L_{00}$ for the conductivity is close to the +expected theoretical value. + +Using Eq.~(\ref{WL}) and the obtained value for $a\approx $~28~k$\Omega $ we can +compare the dephasing length ($L_{\varphi }$) with the thickness ($t\approx $% +~2~nm) at 16~K. For the sample with $R_{0}=$~63903~$\Omega $ the ratio $% +L_{\varphi }/t\approx $~0.5 and for the sample with $R_{0}=$~17573~$\Omega $ +$L_{\varphi }/t\approx $~2. The latter estimate assumes no spin +polarization, while a full polarization would imply $L_{\varphi }/t\approx $% +~1. Thus $L_{\varphi }$ is smaller than or close to the thickness of the +film, which keeps the film in the three-dimensional regime for almost all +temperatures and disorder strengths considered. + +\begin{figure}[tbp] +\begin{center} +\includegraphics[width=9cm]{fig_2_16.eps} +\end{center} +\caption{Dependence of the fitting parameters $B$ and $A$ (inset) on +disorder $R_0$ for $\Delta=$~16~K. The fitting parameters are indicated for +each curve with the error in the least significant digit indicated in +parentheses.} +\label{fig:parb} +\end{figure} + +In conclusion, we have performed \textit{in situ} transport measurements on +ultra thin Mn films, systematically varying the disorder ($R_{0}=R_{xx}$($T=$% +~5~K)). The obtained data were analyzed within a weak localization theory in +3d generalized to strong disorder. In the temperature range considered +inelastic scattering off spin waves is found to be strong giving rise to a +dephasing length shorter than the film thickness, which places these systems +into the 3d regime. The obtained value for the spin wave gap was close to +the one measured by Gao et al.~\cite{gao_2008} using ISTS, while the +exchange energy was much smaller. + +This work has been supported by the NSF under Grant No 1305783 (AFH). +PW thanks A.\ M.\ \ Finkel'stein for useful discussions and acknowledges +partial support through the DFG research unit "Quantum phase transitions". + +\bibliographystyle{apsrev} +\bibliography{bibl} + +\end{document} diff --git a/services/project-history/test/acceptance/fixtures/chunks/0-3.json b/services/project-history/test/acceptance/fixtures/chunks/0-3.json new file mode 100644 index 0000000000..51441cf81d --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/chunks/0-3.json @@ -0,0 +1,74 @@ +{ + "chunk": { + "history": { + "snapshot": { + "files": { + "bar.tex": { + "hash": "4f785a4c192155b240e3042b3a7388b47603f423", + "stringLength": 26 + }, + "main.tex": { + "hash": "f28571f561d198b87c24cc6a98b78e87b665e22d", + "stringLength": 20638, + "metadata": { + "main": true + } + } + } + }, + "changes": [ + { + "operations": [ + { + "pathname": "main.tex", + "textOperation": [ + 1912, + "Hello world", + 18726 + ] + } + ], + "timestamp": "2017-12-04T10:23:35.633Z", + "authors": [ + 31 + ] + }, + { + "operations": [ + { + "pathname": "bar.tex", + "newPathname": "foo.tex" + } + ], + "timestamp": "2017-12-04T10:27:26.874Z", + "authors": [ + 31 + ] + }, + { + "operations": [ + { + "pathname": "foo.tex", + "textOperation": [ + 26, + "\n\nFour five six" + ] + } + ], + "timestamp": "2017-12-04T10:28:33.724Z", + "authors": [ + 31 + ] + } + ] + }, + "startVersion": 0 + }, + "authors": [ + { + "id": 31, + "email": "james.allen@overleaf.com", + "name": "James" + } + ] +} \ No newline at end of file diff --git a/services/project-history/test/acceptance/fixtures/chunks/4-6.json b/services/project-history/test/acceptance/fixtures/chunks/4-6.json new file mode 100644 index 0000000000..24040cfc4e --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/chunks/4-6.json @@ -0,0 +1,74 @@ +{ + "chunk": { + "history": { + "snapshot": { + "files": { + "main.tex": { + "hash": "35c9bd86574d61dcadbce2fdd3d4a0684272c6ea", + "stringLength": 20649, + "metadata": { + "main": true + } + }, + "foo.tex": { + "hash": "c6654ea913979e13e22022653d284444f284a172", + "stringLength": 41 + } + } + }, + "changes": [ + { + "operations": [ + { + "pathname": "foo.tex", + "textOperation": [ + 41, + "\n\nSeven eight nince" + ] + } + ], + "timestamp": "2017-12-04T10:29:17.786Z", + "authors": [ + 31 + ] + }, + { + "operations": [ + { + "pathname": "foo.tex", + "textOperation": [ + 58, + -1, + 1 + ] + } + ], + "timestamp": "2017-12-04T10:29:22.905Z", + "authors": [ + 31 + ] + }, + { + "operations": [ + { + "pathname": "foo.tex", + "newPathname": "bar.tex" + } + ], + "timestamp": "2017-12-04T10:29:26.120Z", + "authors": [ + 31 + ] + } + ] + }, + "startVersion": 3 + }, + "authors": [ + { + "id": 31, + "email": "james.allen@overleaf.com", + "name": "James" + } + ] +} \ No newline at end of file diff --git a/services/project-history/test/acceptance/fixtures/chunks/7-8.json b/services/project-history/test/acceptance/fixtures/chunks/7-8.json new file mode 100644 index 0000000000..4325abc0df --- /dev/null +++ b/services/project-history/test/acceptance/fixtures/chunks/7-8.json @@ -0,0 +1,63 @@ +{ + "chunk": { + "history": { + "snapshot": { + "files": { + "main.tex": { + "hash": "35c9bd86574d61dcadbce2fdd3d4a0684272c6ea", + "stringLength": 20649, + "metadata": { + "main": true + } + }, + "bar.tex": { + "hash": "e13c315d53aaef3aa34550a86b09cff091ace220", + "stringLength": 59 + } + } + }, + "changes": [ + { + "operations": [ + { + "pathname": "main.tex", + "textOperation": [ + 1923, + " also updated", + 18726 + ] + } + ], + "timestamp": "2017-12-04T10:32:47.277Z", + "authors": [ + 31 + ] + }, + { + "operations": [ + { + "pathname": "bar.tex", + "textOperation": [ + 28, + -15, + 16 + ] + } + ], + "timestamp": "2017-12-04T10:32:52.877Z", + "v2Authors": [ + "5a5637efdac84e81b71014c4" + ] + } + ] + }, + "startVersion": 6 + }, + "authors": [ + { + "id": 31, + "email": "james.allen@overleaf.com", + "name": "James" + } + ] +} \ No newline at end of file diff --git a/services/project-history/test/acceptance/js/DeleteProjectTests.js b/services/project-history/test/acceptance/js/DeleteProjectTests.js new file mode 100644 index 0000000000..670ccdb17f --- /dev/null +++ b/services/project-history/test/acceptance/js/DeleteProjectTests.js @@ -0,0 +1,82 @@ +import { expect } from 'chai' +import nock from 'nock' +import { ObjectId } from 'mongodb' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') +const fixture = path => new URL(`../fixtures/${path}`, import.meta.url) + +describe('Deleting project', function () { + beforeEach(function (done) { + this.projectId = ObjectId().toString() + this.historyId = ObjectId().toString() + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: this.historyId } }, + }) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/latest/history`) + .replyWithFile(200, fixture('chunks/0-3.json')) + MockHistoryStore().delete(`/api/projects/${this.historyId}`).reply(204) + ProjectHistoryApp.ensureRunning(done) + }) + + describe('when the project has no pending updates', function (done) { + it('successfully deletes the project', function (done) { + ProjectHistoryClient.deleteProject(this.projectId, done) + }) + }) + + describe('when the project has pending updates', function (done) { + beforeEach(function (done) { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + { + pathname: '/main.tex', + docLines: 'hello', + doc: this.docId, + meta: { userId: this.userId, ts: new Date() }, + }, + err => { + if (err) { + return done(err) + } + ProjectHistoryClient.setFirstOpTimestamp( + this.projectId, + Date.now(), + err => { + if (err) { + return done(err) + } + ProjectHistoryClient.deleteProject(this.projectId, done) + } + ) + } + ) + }) + + it('clears pending updates', function (done) { + ProjectHistoryClient.getDump(this.projectId, (err, dump) => { + if (err) { + return done(err) + } + expect(dump.updates).to.deep.equal([]) + done() + }) + }) + + it('clears the first op timestamp', function (done) { + ProjectHistoryClient.getFirstOpTimestamp(this.projectId, (err, ts) => { + if (err) { + return done(err) + } + expect(ts).to.be.null + done() + }) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/DiffTests.js b/services/project-history/test/acceptance/js/DiffTests.js new file mode 100644 index 0000000000..1b261ee506 --- /dev/null +++ b/services/project-history/test/acceptance/js/DiffTests.js @@ -0,0 +1,414 @@ +import { expect } from 'chai' +import request from 'request' +import crypto from 'crypto' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +function createMockBlob(historyId, content) { + const sha = crypto.createHash('sha1').update(content).digest('hex') + MockHistoryStore() + .get(`/api/projects/${historyId}/blobs/${sha}`) + .reply(200, content) + .persist() + return sha +} + +describe('Diffs', function () { + beforeEach(function (done) { + ProjectHistoryApp.ensureRunning(error => { + if (error) { + throw error + } + + this.historyId = ObjectId().toString() + this.projectId = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: this.historyId, + }) + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: this.historyId } }, + }) + + ProjectHistoryClient.initializeProject(this.historyId, error => { + if (error) { + return done(error) + } + done() + }) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + it('should return a diff of the updates to a doc from a single chunk', function (done) { + this.blob = 'one two three five' + this.sha = createMockBlob(this.historyId, this.blob) + this.v2AuthorId = '123456789' + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: this.sha, + stringLength: this.blob.length, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [13, ' four', 5], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [4, -4, 15], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [19, ' six'], + }, + ], + timestamp: '2017-12-04T10:29:26.120Z', + v2Authors: [this.v2AuthorId], + }, + ], + }, + startVersion: 3, + }, + authors: [31], + }) + + ProjectHistoryClient.getDiff( + this.projectId, + 'foo.tex', + 3, + 6, + (error, diff) => { + if (error) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + u: 'one ', + }, + { + d: 'two ', + meta: { + users: [31], + start_ts: 1512383362905, + end_ts: 1512383362905, + }, + }, + { + u: 'three', + }, + { + i: ' four', + meta: { + users: [31], + start_ts: 1512383357786, + end_ts: 1512383357786, + }, + }, + { + u: ' five', + }, + { + i: ' six', + meta: { + users: [this.v2AuthorId], + start_ts: 1512383366120, + end_ts: 1512383366120, + }, + }, + ], + }) + done() + } + ) + }) + + it('should return a diff of the updates to a doc across multiple chunks', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: createMockBlob(this.historyId, 'one two three five'), + stringLength: 'one three four five'.length, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [13, ' four', 5], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [4, -4, 15], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: createMockBlob(this.historyId, 'one three four five'), + stringLength: 'one three four five'.length, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [19, ' six'], + }, + ], + timestamp: '2017-12-04T10:29:26.120Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [23, ' seven'], + }, + ], + timestamp: '2017-12-04T10:29:26.120Z', + authors: [31], + }, + ], + }, + startVersion: 5, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + ProjectHistoryClient.getDiff( + this.projectId, + 'foo.tex', + 4, + 6, + (error, diff) => { + if (error) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + u: 'one ', + }, + { + d: 'two ', + meta: { + users: [31], + start_ts: 1512383362905, + end_ts: 1512383362905, + }, + }, + { + u: 'three four five', + }, + { + i: ' six', + meta: { + users: [31], + start_ts: 1512383366120, + end_ts: 1512383366120, + }, + }, + ], + }) + done() + } + ) + }) + + it('should return a 404 when there are no changes for the file in the range', function (done) { + this.blob = 'one two three five' + this.sha = createMockBlob(this.historyId, this.blob) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: this.sha, + stringLength: this.blob.length, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [13, ' four', 5], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [31], + }) + + request.get( + { + url: `http://localhost:3054/project/${this.projectId}/diff`, + qs: { + pathname: 'not_here.tex', + from: 3, + to: 6, + }, + json: true, + }, + (error, res, body) => { + if (error) { + throw error + } + expect(res.statusCode).to.equal(404) + done() + } + ) + }) + + it('should return a binary flag with a diff of a binary file', function (done) { + this.blob = 'one two three five' + this.sha = createMockBlob(this.historyId, this.blob) + this.binaryBlob = Buffer.from([1, 2, 3, 4]) + this.binarySha = createMockBlob(this.historyId, this.binaryBlob) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'binary.tex': { + hash: this.binarySha, + byteLength: this.binaryBlob.length, // Indicates binary + }, + 'foo.tex': { + hash: this.sha, + stringLength: this.blob.length, // Indicates binary + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [13, ' four', 5], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [4, -4, 15], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: [19, ' six'], + }, + ], + timestamp: '2017-12-04T10:29:26.120Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + ProjectHistoryClient.getDiff( + this.projectId, + 'binary.tex', + 3, + 6, + (error, diff) => { + if (error) { + throw error + } + expect(diff).to.deep.equal({ + diff: { + binary: true, + }, + }) + done() + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/DiscardingUpdatesTests.js b/services/project-history/test/acceptance/js/DiscardingUpdatesTests.js new file mode 100644 index 0000000000..708b89df71 --- /dev/null +++ b/services/project-history/test/acceptance/js/DiscardingUpdatesTests.js @@ -0,0 +1,72 @@ +/* eslint-disable + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import async from 'async' +import sinon from 'sinon' +import { expect } from 'chai' +import Settings from '@overleaf/settings' +import assert from 'assert' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +describe('DiscardingUpdates', function () { + beforeEach(function (done) { + this.timestamp = new Date() + + return ProjectHistoryApp.ensureRunning(error => { + if (error != null) { + throw error + } + this.user_id = ObjectId().toString() + this.project_id = ObjectId().toString() + this.doc_id = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: 0, + }) + MockWeb() + .get(`/project/${this.project_id}/details`) + .reply(200, { name: 'Test Project' }) + return ProjectHistoryClient.initializeProject(this.project_id, done) + }) + }) + + return it('should discard updates', function (done) { + return async.series( + [ + cb => { + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.doc_id, + meta: { user_id: this.user_id, ts: new Date() }, + } + return ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + return ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error != null) { + throw error + } + return done() + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/FileTreeDiffTests.js b/services/project-history/test/acceptance/js/FileTreeDiffTests.js new file mode 100644 index 0000000000..a08a2ad6fa --- /dev/null +++ b/services/project-history/test/acceptance/js/FileTreeDiffTests.js @@ -0,0 +1,856 @@ +/* eslint-disable + camelcase, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import Settings from '@overleaf/settings' +import request from 'request' +import assert from 'assert' +import Path from 'path' +import crypto from 'crypto' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' +import * as HistoryId from './helpers/HistoryId.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockFileStore = () => nock('http://localhost:3009') +const MockWeb = () => nock('http://localhost:3000') + +const sha = data => crypto.createHash('sha1').update(data).digest('hex') + +describe('FileTree Diffs', function () { + beforeEach(function (done) { + return ProjectHistoryApp.ensureRunning(error => { + if (error != null) { + throw error + } + + this.historyId = ObjectId().toString() + this.projectId = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: this.historyId, + }) + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: this.historyId } }, + }) + + return ProjectHistoryClient.initializeProject( + this.historyId, + (error, ol_project) => { + if (error != null) { + throw error + } + return done() + } + ) + }) + }) + + afterEach(function () { + return nock.cleanAll() + }) + + it('should return a diff of the updates to a doc from a single chunk', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/7/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: sha('mock-sha-foo'), + stringLength: 42, + }, + 'renamed.tex': { + hash: sha('mock-sha-renamed'), + stringLength: 42, + }, + 'deleted.tex': { + hash: sha('mock-sha-deleted'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'renamed.tex', + newPathname: 'newName.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: ['lorem ipsum'], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'deleted.tex', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + { + operations: [ + { + file: { + hash: sha('new-sha'), + }, + pathname: 'added.tex', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 7, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + pathname: 'foo.tex', + operation: 'edited', + }, + { + pathname: 'deleted.tex', + operation: 'removed', + deletedAtV: 5, + }, + { + newPathname: 'newName.tex', + pathname: 'renamed.tex', + operation: 'renamed', + }, + { + pathname: 'added.tex', + operation: 'added', + }, + ], + }) + return done() + } + ) + }) + + it('should return a diff of the updates to a doc across multiple chunks', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + // Updated in this chunk + hash: sha('mock-sha-foo'), + stringLength: 42, + }, + 'bar.tex': { + // Updated in the next chunk + hash: sha('mock-sha-bar'), + stringLength: 42, + }, + 'baz.tex': { + // Not updated + hash: sha('mock-sha-bar'), + stringLength: 42, + }, + 'renamed.tex': { + hash: sha('mock-sha-renamed'), + stringLength: 42, + }, + 'deleted.tex': { + hash: sha('mock-sha-deleted'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'renamed.tex', + newPathname: 'newName.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'foo.tex', + textOperation: ['lorem ipsum'], + }, + ], + timestamp: '2017-12-04T10:29:19.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'deleted.tex', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 2, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/7/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: sha('mock-sha-foo'), + stringLength: 42, + }, + 'baz.tex': { + hash: sha('mock-sha-bar'), + stringLength: 42, + }, + 'newName.tex': { + hash: sha('mock-sha-renamed'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + file: { + hash: sha('new-sha'), + }, + pathname: 'added.tex', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'bar.tex', + textOperation: ['lorem ipsum'], + }, + ], + timestamp: '2017-12-04T10:29:23.786Z', + authors: [31], + }, + ], + }, + startVersion: 5, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 2, + 7, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + pathname: 'foo.tex', + operation: 'edited', + }, + { + pathname: 'bar.tex', + operation: 'edited', + }, + { + pathname: 'baz.tex', + }, + { + pathname: 'deleted.tex', + operation: 'removed', + deletedAtV: 4, + }, + { + newPathname: 'newName.tex', + pathname: 'renamed.tex', + operation: 'renamed', + }, + { + pathname: 'added.tex', + operation: 'added', + }, + ], + }) + return done() + } + ) + }) + + it('should return a diff that includes multiple renames', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'one.tex': { + hash: sha('mock-sha'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'one.tex', + newPathname: 'two.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'two.tex', + newPathname: 'three.tex', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + newPathname: 'three.tex', + pathname: 'one.tex', + operation: 'renamed', + }, + ], + }) + return done() + } + ) + }) + + it('should handle deleting the re-adding a file', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'one.tex': { + hash: sha('mock-sha'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'one.tex', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'one.tex', + file: { + hash: sha('mock-sha'), + }, + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + pathname: 'one.tex', + }, + ], + }) + return done() + } + ) + }) + + it('should handle deleting the renaming a file to the same place', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'one.tex': { + hash: sha('mock-sha-one'), + stringLength: 42, + }, + 'two.tex': { + hash: sha('mock-sha-two'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'one.tex', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'two.tex', + newPathname: 'one.tex', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + pathname: 'two.tex', + newPathname: 'one.tex', + operation: 'renamed', + }, + ], + }) + return done() + } + ) + }) + + it('should handle adding then renaming a file', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'one.tex', + file: { + hash: sha('mock-sha'), + }, + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'one.tex', + newPathname: 'two.tex', + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + pathname: 'two.tex', + operation: 'added', + }, + ], + }) + return done() + } + ) + }) + + it('should return 422 with a chunk with an invalid rename', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: sha('mock-sha-foo'), + stringLength: 42, + }, + 'bar.tex': { + hash: sha('mock-sha-bar'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'foo.tex', + newPathname: 'bar.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 5, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 5, + 6, + (error, diff, statusCode) => { + if (error != null) { + throw error + } + expect(statusCode).to.equal(422) + return done() + } + ) + }) + + it('should return 422 with a chunk with an invalid add', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'foo.tex': { + hash: sha('mock-sha-foo'), + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + file: { + hash: sha('new-sha'), + }, + pathname: 'foo.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 5, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 5, + 6, + (error, diff, statusCode) => { + if (error != null) { + throw error + } + expect(statusCode).to.equal(422) + return done() + } + ) + }) + + it('should handle edits of missing/invalid files ', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'new.tex', + textOperation: ['lorem ipsum'], + }, + ], + timestamp: '2017-12-04T10:29:18.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: '', + textOperation: ['lorem ipsum'], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [ + { + operation: 'edited', + pathname: 'new.tex', + }, + ], + }) + return done() + } + ) + }) + + it('should handle deletions of missing/invalid files ', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'missing.tex', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: '', + newPathname: '', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [], + }) + return done() + } + ) + }) + + return it('should handle renames of missing/invalid files ', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'missing.tex', + newPathname: 'missing-renamed.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: '', + newPathname: 'missing-renamed-other.tex', + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + ], + }, + startVersion: 3, + }, + authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }], + }) + + return ProjectHistoryClient.getFileTreeDiff( + this.projectId, + 3, + 5, + (error, diff) => { + if (error != null) { + throw error + } + expect(diff).to.deep.equal({ + diff: [], + }) + return done() + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/FlushManagerTests.js b/services/project-history/test/acceptance/js/FlushManagerTests.js new file mode 100644 index 0000000000..15e60b2ae1 --- /dev/null +++ b/services/project-history/test/acceptance/js/FlushManagerTests.js @@ -0,0 +1,244 @@ +import async from 'async' +import nock from 'nock' +import { expect } from 'chai' +import request from 'request' +import assert from 'assert' +import { ObjectId } from 'mongodb' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +describe('Flushing old queues', function () { + const historyId = ObjectId().toString() + + beforeEach(function (done) { + this.timestamp = new Date() + + ProjectHistoryApp.ensureRunning(error => { + if (error) { + throw error + } + this.projectId = ObjectId().toString() + this.docId = ObjectId().toString() + this.fileId = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: historyId, + }) + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + changes: [], + }, + }, + }) + ProjectHistoryClient.initializeProject(historyId, done) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + describe('retrying an unflushed project', function () { + describe('when the update is older than the cutoff', function () { + beforeEach(function (done) { + this.flushCall = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(201) + .post(`/api/projects/${historyId}/legacy_changes?end_version=0`) + .reply(200) + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.docId, + meta: { user_id: this.user_id, ts: new Date() }, + } + async.series( + [ + cb => + ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb), + cb => + ProjectHistoryClient.setFirstOpTimestamp( + this.projectId, + Date.now() - 24 * 3600 * 1000, + cb + ), + ], + done + ) + }) + + it('flushes the project history queue', function (done) { + request.post( + { + url: 'http://localhost:3054/flush/old?maxAge=10800', + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates' + ) + done() + } + ) + }) + + it('flushes the project history queue in the background when requested', function (done) { + request.post( + { + url: 'http://localhost:3054/flush/old?maxAge=10800&background=1', + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + expect(body).to.equal('{"message":"running flush in background"}') + assert( + !this.flushCall.isDone(), + 'did not make calls to history service to store updates in the foreground' + ) + setTimeout(() => { + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates in the background' + ) + done() + }, 100) + } + ) + }) + }) + + describe('when the update is newer than the cutoff', function () { + beforeEach(function (done) { + this.flushCall = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(201) + .post(`/api/projects/${historyId}/legacy_changes?end_version=0`) + .reply(200) + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.docId, + meta: { user_id: this.user_id, ts: new Date() }, + } + async.series( + [ + cb => + ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb), + cb => + ProjectHistoryClient.setFirstOpTimestamp( + this.projectId, + Date.now() - 60 * 1000, + cb + ), + ], + done + ) + }) + + it('does not flush the project history queue', function (done) { + request.post( + { + url: `http://localhost:3054/flush/old?maxAge=${3 * 3600}`, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert( + !this.flushCall.isDone(), + 'did not make calls to history service to store updates' + ) + done() + } + ) + }) + }) + + describe('when the update does not have a timestamp', function () { + beforeEach(function (done) { + this.flushCall = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(201) + .post(`/api/projects/${historyId}/legacy_changes?end_version=0`) + .reply(200) + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.docId, + meta: { user_id: this.user_id, ts: new Date() }, + } + this.startDate = Date.now() + async.series( + [ + cb => + ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb), + cb => + ProjectHistoryClient.clearFirstOpTimestamp(this.projectId, cb), + ], + done + ) + }) + + it('sets the timestamp and does not flush the project history queue', function (done) { + request.post( + { + url: `http://localhost:3054/flush/old?maxAge=${3 * 3600}`, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert( + !this.flushCall.isDone(), + 'did not make calls to history service to store updates' + ) + ProjectHistoryClient.getFirstOpTimestamp( + this.projectId, + (err, result) => { + if (err) { + return done(err) + } + expect(parseInt(result, 10)).to.be.within( + this.startDate, + Date.now() + ) + done() + } + ) + } + ) + }) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/HealthCheckTests.js b/services/project-history/test/acceptance/js/HealthCheckTests.js new file mode 100644 index 0000000000..0032da112b --- /dev/null +++ b/services/project-history/test/acceptance/js/HealthCheckTests.js @@ -0,0 +1,76 @@ +/* eslint-disable + camelcase, + no-undef, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { expect } from 'chai' +import settings from '@overleaf/settings' +import request from 'request' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +describe('Health Check', function () { + beforeEach(function (done) { + const project_id = ObjectId() + const historyId = ObjectId().toString() + settings.history.healthCheck = { project_id } + return ProjectHistoryApp.ensureRunning(error => { + if (error != null) { + throw error + } + MockHistoryStore().post('/api/projects').reply(200, { + projectId: historyId, + }) + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + MockWeb() + .get(`/project/${project_id}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + + return ProjectHistoryClient.initializeProject(historyId, done) + }) + }) + + return it('should respond to the health check', function (done) { + return request.get( + { + url: 'http://localhost:3054/health_check', + }, + (error, res, body) => { + if (error != null) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + return done() + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/LabelsTests.js b/services/project-history/test/acceptance/js/LabelsTests.js new file mode 100644 index 0000000000..b76ef30b1c --- /dev/null +++ b/services/project-history/test/acceptance/js/LabelsTests.js @@ -0,0 +1,253 @@ +/* eslint-disable + camelcase, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import Settings from '@overleaf/settings' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockFileStore = () => nock('http://localhost:3009') +const MockWeb = () => nock('http://localhost:3000') + +const fixture = path => new URL(`../fixtures/${path}`, import.meta.url) + +describe('Labels', function () { + beforeEach(function (done) { + return ProjectHistoryApp.ensureRunning(error => { + if (error != null) { + throw error + } + + this.historyId = ObjectId().toString() + MockHistoryStore().post('/api/projects').reply(200, { + projectId: this.historyId, + }) + + return ProjectHistoryClient.initializeProject( + this.historyId, + (error, ol_project) => { + if (error != null) { + throw error + } + this.project_id = ObjectId().toString() + MockWeb() + .get(`/project/${this.project_id}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: ol_project.id } }, + }) + + MockHistoryStore() + .get(`/api/projects/${this.historyId}/latest/history`) + .replyWithFile(200, fixture('chunks/7-8.json')) + + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/7/history`) + .replyWithFile(200, fixture('chunks/7-8.json')) + .persist() + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/8/history`) + .replyWithFile(200, fixture('chunks/7-8.json')) + .persist() + + this.comment = 'a saved version comment' + this.comment2 = 'another saved version comment' + this.user_id = ObjectId().toString() + this.created_at = new Date(1) + return done() + } + ) + }) + }) + + afterEach(function () { + return nock.cleanAll() + }) + + it('can create and get labels', function (done) { + return ProjectHistoryClient.createLabel( + this.project_id, + this.user_id, + 7, + this.comment, + this.created_at, + (error, label) => { + if (error != null) { + throw error + } + return ProjectHistoryClient.getLabels( + this.project_id, + (error, labels) => { + if (error != null) { + throw error + } + expect(labels).to.deep.equal([label]) + return done() + } + ) + } + ) + }) + + it('can delete labels', function (done) { + return ProjectHistoryClient.createLabel( + this.project_id, + this.user_id, + 7, + this.comment, + this.created_at, + (error, label) => { + if (error != null) { + throw error + } + return ProjectHistoryClient.deleteLabel( + this.project_id, + this.user_id, + label.id, + error => { + if (error != null) { + throw error + } + return ProjectHistoryClient.getLabels( + this.project_id, + (error, labels) => { + if (error != null) { + throw error + } + expect(labels).to.deep.equal([]) + return done() + } + ) + } + ) + } + ) + }) + + it('can transfer ownership of labels', function (done) { + const from_user = ObjectId().toString() + const to_user = ObjectId().toString() + return ProjectHistoryClient.createLabel( + this.project_id, + from_user, + 7, + this.comment, + this.created_at, + (error, label) => { + if (error != null) { + throw error + } + return ProjectHistoryClient.createLabel( + this.project_id, + from_user, + 7, + this.comment2, + this.created_at, + (error, label2) => { + if (error != null) { + throw error + } + return ProjectHistoryClient.transferLabelOwnership( + from_user, + to_user, + error => { + if (error != null) { + throw error + } + return ProjectHistoryClient.getLabels( + this.project_id, + (error, labels) => { + if (error != null) { + throw error + } + expect(labels).to.deep.equal([ + { + id: label.id, + comment: label.comment, + version: label.version, + created_at: label.created_at, + user_id: to_user, + }, + { + id: label2.id, + comment: label2.comment, + version: label2.version, + created_at: label2.created_at, + user_id: to_user, + }, + ]) + return done() + } + ) + } + ) + } + ) + } + ) + }) + + return it('should return labels with summarized updates', function (done) { + return ProjectHistoryClient.createLabel( + this.project_id, + this.user_id, + 8, + this.comment, + this.created_at, + (error, label) => { + if (error != null) { + throw error + } + return ProjectHistoryClient.getSummarizedUpdates( + this.project_id, + { min_count: 1 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates).to.deep.equal({ + nextBeforeTimestamp: 6, + updates: [ + { + fromV: 6, + toV: 8, + meta: { + users: ['5a5637efdac84e81b71014c4', 31], + start_ts: 1512383567277, + end_ts: 1512383572877, + }, + pathnames: ['bar.tex', 'main.tex'], + project_ops: [], + labels: [ + { + id: label.id.toString(), + comment: this.comment, + version: 8, + user_id: this.user_id, + created_at: this.created_at.toISOString(), + }, + ], + }, + ], + }) + return done() + } + ) + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/ReadingASnapshotTests.js b/services/project-history/test/acceptance/js/ReadingASnapshotTests.js new file mode 100644 index 0000000000..1bc413f80d --- /dev/null +++ b/services/project-history/test/acceptance/js/ReadingASnapshotTests.js @@ -0,0 +1,297 @@ +import { expect } from 'chai' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +const fixture = path => new URL(`../fixtures/${path}`, import.meta.url) + +describe('ReadSnapshot', function () { + beforeEach(function (done) { + ProjectHistoryApp.ensureRunning(error => { + if (error) { + throw error + } + + this.historyId = ObjectId().toString() + MockHistoryStore().post('/api/projects').reply(200, { + projectId: this.historyId, + }) + + ProjectHistoryClient.initializeProject( + this.historyId, + (error, v1Project) => { + if (error) { + throw error + } + this.projectId = ObjectId().toString() + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: v1Project.id } }, + }) + done() + } + ) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + describe('of a text file', function () { + it('should return the snapshot of a doc at the given version', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .replyWithFile( + 200, + fixture('blobs/c6654ea913979e13e22022653d284444f284a172') + ) + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'foo.tex', + 5, + (error, body) => { + if (error) { + throw error + } + expect(body).to.deep.equal( + `\ +Hello world + +One two three + +Four five six + +Seven eight nine\ +`.replace(/^\t/g, '') + ) + done() + } + ) + }) + + it('should return the snapshot of a doc at a different version', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/4/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .replyWithFile( + 200, + fixture('blobs/c6654ea913979e13e22022653d284444f284a172') + ) + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'foo.tex', + 4, + (error, body) => { + if (error) { + throw error + } + expect(body).to.deep.equal( + `\ +Hello world + +One two three + +Four five six + +Seven eight nince\ +`.replace(/^\t/g, '') + ) + done() + } + ) + }) + + it('should return the snapshot of a doc after a rename version', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .replyWithFile( + 200, + fixture('blobs/c6654ea913979e13e22022653d284444f284a172') + ) + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'bar.tex', + 6, + (error, body) => { + if (error) { + throw error + } + expect(body).to.deep.equal( + `\ +Hello world + +One two three + +Four five six + +Seven eight nine\ +`.replace(/^\t/g, '') + ) + done() + } + ) + }) + }) + + describe('of a binary file', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/4/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + binary_file: { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [], + }, + startVersion: 3, + }, + authors: [], + }) + }) + + it('should return the snapshot of the file at the given version', function (done) { + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .replyWithFile( + 200, + fixture('blobs/c6654ea913979e13e22022653d284444f284a172') + ) + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'binary_file', + 4, + (error, body) => { + if (error) { + throw error + } + expect(body).to.deep.equal( + `\ +Hello world + +One two three + +Four five six\ +`.replace(/^\t/g, '') + ) + done() + } + ) + }) + + it("should return an error when the blob doesn't exist", function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/4/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + binary_file: { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [], + }, + startVersion: 3, + }, + authors: [], + }) + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .reply(404) + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'binary_file', + 4, + { allowErrors: true }, + (error, body, statusCode) => { + if (error) { + throw error + } + expect(statusCode).to.equal(500) + done() + } + ) + }) + + it('should return an error when the blob request errors', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/4/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + binary_file: { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [], + }, + startVersion: 3, + }, + authors: [], + }) + MockHistoryStore() + .get( + `/api/projects/${this.historyId}/blobs/c6654ea913979e13e22022653d284444f284a172` + ) + .replyWithError('oh no!') + + ProjectHistoryClient.getSnapshot( + this.projectId, + 'binary_file', + 4, + { allowErrors: true }, + (error, body, statusCode) => { + if (error) { + throw error + } + expect(statusCode).to.equal(500) + done() + } + ) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/RetryTests.js b/services/project-history/test/acceptance/js/RetryTests.js new file mode 100644 index 0000000000..f330de8213 --- /dev/null +++ b/services/project-history/test/acceptance/js/RetryTests.js @@ -0,0 +1,193 @@ +import async from 'async' +import nock from 'nock' +import { expect } from 'chai' +import request from 'request' +import assert from 'assert' +import { ObjectId } from 'mongodb' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockWeb = () => nock('http://localhost:3000') + +const MockCallback = () => nock('http://localhost') + +describe('Retrying failed projects', function () { + const historyId = ObjectId().toString() + + beforeEach(function (done) { + this.timestamp = new Date() + + ProjectHistoryApp.ensureRunning(error => { + if (error) { + throw error + } + this.project_id = ObjectId().toString() + this.doc_id = ObjectId().toString() + this.file_id = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: historyId, + }) + MockWeb() + .get(`/project/${this.project_id}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + changes: [], + }, + }, + }) + ProjectHistoryClient.initializeProject(historyId, done) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + describe('retrying project history', function () { + describe('when there is a soft failure', function () { + beforeEach(function (done) { + this.flushCall = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(201) + .post(`/api/projects/${historyId}/legacy_changes?end_version=0`) + .reply(200) + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.doc_id, + meta: { user_id: this.user_id, ts: new Date() }, + } + async.series( + [ + cb => + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb), + cb => + ProjectHistoryClient.setFailure( + { + project_id: this.project_id, + attempts: 1, + error: 'soft-error', + }, + cb + ), + ], + done + ) + }) + + it('flushes the project history queue', function (done) { + request.post( + { + url: 'http://localhost:3054/retry/failures?failureType=soft&limit=1&timeout=10000', + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates' + ) + done() + } + ) + }) + + it('retries in the background when requested', function (done) { + this.callback = MockCallback() + .matchHeader('Authorization', '123') + .get('/ping') + .reply(200) + request.post( + { + url: 'http://localhost:3054/retry/failures?failureType=soft&limit=1&timeout=10000&callbackUrl=http%3A%2F%2Flocalhost%2Fping', + headers: { + 'X-CALLBACK-Authorization': '123', + }, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + expect(body).to.equal( + '{"retryStatus":"running retryFailures in background"}' + ) + assert( + !this.flushCall.isDone(), + 'did not make calls to history service to store updates in the foreground' + ) + setTimeout(() => { + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates in the background' + ) + assert(this.callback.isDone(), 'hit the callback url') + done() + }, 100) + } + ) + }) + }) + + describe('when there is a hard failure', function () { + beforeEach(function (done) { + MockWeb() + .get(`/project/${this.project_id}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + ProjectHistoryClient.setFailure( + { + project_id: this.project_id, + attempts: 100, + error: 'hard-error', + }, + done + ) + }) + + it('calls web to resync the project', function (done) { + const resyncCall = MockWeb() + .post(`/project/${this.project_id}/history/resync`) + .reply(200) + + request.post( + { + url: 'http://localhost:3054/retry/failures?failureType=hard&limit=1&timeout=10000', + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert(resyncCall.isDone(), 'made a call to web to resync project') + done() + } + ) + }) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/SendingUpdatesTests.js b/services/project-history/test/acceptance/js/SendingUpdatesTests.js new file mode 100644 index 0000000000..3f3e049abd --- /dev/null +++ b/services/project-history/test/acceptance/js/SendingUpdatesTests.js @@ -0,0 +1,1983 @@ +import { expect } from 'chai' +import Settings from '@overleaf/settings' +import assert from 'assert' +import async from 'async' +import crypto from 'crypto' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockFileStore = () => nock('http://localhost:3009') +const MockWeb = () => nock('http://localhost:3000') + +// Some helper methods to make the tests more compact +function slTextUpdate(historyId, doc, userId, v, ts, op) { + return { + projectHistoryId: historyId, + doc: doc.id, + op, + v, + + meta: { + user_id: userId, + ts: ts.getTime(), + pathname: doc.pathname, + doc_length: doc.length, + }, + } +} + +function slAddDocUpdate(historyId, doc, userId, ts, docLines) { + return { + projectHistoryId: historyId, + pathname: doc.pathname, + docLines, + doc: doc.id, + meta: { user_id: userId, ts: ts.getTime() }, + } +} + +function slAddDocUpdateWithVersion( + historyId, + doc, + userId, + ts, + docLines, + projectVersion +) { + const result = slAddDocUpdate(historyId, doc, userId, ts, docLines) + result.version = projectVersion + return result +} + +function slAddFileUpdate(historyId, file, userId, ts, projectId) { + return { + projectHistoryId: historyId, + pathname: file.pathname, + url: `http://localhost:3009/project/${projectId}/file/${file.id}`, + file: file.id, + meta: { user_id: userId, ts: ts.getTime() }, + } +} + +function slRenameUpdate(historyId, doc, userId, ts, pathname, newPathname) { + return { + projectHistoryId: historyId, + pathname, + new_pathname: newPathname, + doc: doc.id, + meta: { user_id: userId, ts: ts.getTime() }, + } +} + +function olTextUpdate(doc, userId, ts, textOperation, v) { + return { + v2Authors: [userId], + timestamp: ts.toJSON(), + authors: [], + + operations: [ + { + pathname: doc.pathname.replace(/^\//, ''), // Strip leading / + textOperation, + }, + ], + + v2DocVersions: { + [doc.id]: { + pathname: doc.pathname.replace(/^\//, ''), // Strip leading / + v: v || 1, + }, + }, + } +} + +function olTextUpdates(doc, userId, ts, textOperations, v) { + return { + v2Authors: [userId], + timestamp: ts.toJSON(), + authors: [], + + operations: textOperations.map(textOperation => ({ + // Strip leading / + pathname: doc.pathname.replace(/^\//, ''), + + textOperation, + })), + + v2DocVersions: { + [doc.id]: { + pathname: doc.pathname.replace(/^\//, ''), // Strip leading / + v: v || 1, + }, + }, + } +} + +function olRenameUpdate(doc, userId, ts, pathname, newPathname) { + return { + v2Authors: [userId], + timestamp: ts.toJSON(), + authors: [], + + operations: [ + { + pathname, + newPathname, + }, + ], + } +} + +function olAddDocUpdate(doc, userId, ts, fileHash) { + return { + v2Authors: [userId], + timestamp: ts.toJSON(), + authors: [], + + operations: [ + { + pathname: doc.pathname.replace(/^\//, ''), // Strip leading / + file: { + hash: fileHash, + }, + }, + ], + } +} + +function olAddDocUpdateWithVersion(doc, userId, ts, fileHash, version) { + const result = olAddDocUpdate(doc, userId, ts, fileHash) + result.projectVersion = version + return result +} + +function olAddFileUpdate(file, userId, ts, fileHash) { + return { + v2Authors: [userId], + timestamp: ts.toJSON(), + authors: [], + + operations: [ + { + pathname: file.pathname.replace(/^\//, ''), // Strip leading / + file: { + hash: fileHash, + }, + }, + ], + } +} + +describe('Sending Updates', function () { + const historyId = ObjectId().toString() + + beforeEach(function (done) { + this.timestamp = new Date() + + ProjectHistoryApp.ensureRunning(error => { + if (error) { + return done(error) + } + this.userId = ObjectId().toString() + this.projectId = ObjectId().toString() + this.docId = ObjectId().toString() + + this.doc = { + id: this.docId, + pathname: '/main.tex', + length: 5, + } + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: historyId, + }) + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + ProjectHistoryClient.initializeProject(historyId, done) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + describe('basic update types', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + }) + + it('should send add doc updates to the history store', function (done) { + const fileHash = '0a207c060e61f3b88eaee0a8cd0696f46fb155eb' + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${fileHash}`, 'a\nb') + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddDocUpdate(this.doc, this.userId, this.timestamp, fileHash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdate( + historyId, + this.doc, + this.userId, + this.timestamp, + 'a\nb' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createBlob.isDone(), + '/api/projects/:historyId/blobs/:hash should have been called' + ) + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should strip non-BMP characters in add doc updates before sending to the history store', function (done) { + const fileHash = '11509fe05a41f9cdc51ea081342b5a4fc7c8d0fc' + + const createBlob = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/${fileHash}`, + 'a\nb\uFFFD\uFFFDc' + ) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddDocUpdate(this.doc, this.userId, this.timestamp, fileHash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdate( + historyId, + this.doc, + this.userId, + this.timestamp, + 'a\nb\uD800\uDC00c' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createBlob.isDone(), + '/api/projects/:historyId/blobs/:hash should have been called' + ) + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should send text updates to the history store', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(this.doc, this.userId, this.timestamp, [3, '\nc', 2]), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [{ p: 3, i: '\nc' }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should send renames to the history store', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olRenameUpdate( + this.doc, + this.userId, + this.timestamp, + 'main.tex', + 'main2.tex' + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slRenameUpdate( + historyId, + this.doc, + this.userId, + this.timestamp, + '/main.tex', + '/main2.tex' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should send add file updates to the history store', function (done) { + const file = { + id: ObjectId().toString(), + pathname: '/test.png', + contents: Buffer.from([1, 2, 3]), + hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74', + } + + const fileStoreRequest = MockFileStore() + .get(`/project/${this.projectId}/file/${file.id}`) + .reply(200, file.contents) + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddFileUpdate(file, this.userId, this.timestamp, file.hash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddFileUpdate( + historyId, + file, + this.userId, + this.timestamp, + this.projectId + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + fileStoreRequest.isDone(), + `/project/${this.projectId}/file/${file.id} should have been called` + ) + assert( + createBlob.isDone(), + `/api/projects/${historyId}/latest/files should have been called` + ) + assert( + addFile.isDone(), + `/api/projects/${historyId}/latest/files should have been called` + ) + done() + } + ) + }) + + it('should send a stub to the history store when the file is large', function (done) { + const fileContents = Buffer.alloc(Settings.maxFileSizeInBytes + 1, 'X') + const fileSize = Buffer.byteLength(fileContents) + + const fileHash = crypto + .createHash('sha1') + .update('blob ' + fileSize + '\x00') + .update(fileContents, 'utf8') + .digest('hex') + + const file = { + id: ObjectId().toString(), + pathname: '/large.png', + contents: fileContents, + hash: fileHash, + } + + const stubContents = [ + 'FileTooLargeError v1', + 'File too large to be stored in history service', + `id project-${this.projectId}-file-${file.id}`, + `size ${fileSize} bytes`, + `hash ${fileHash}`, + '\0', // null byte to make this a binary file + ].join('\n') + + const stubHash = crypto + .createHash('sha1') + .update('blob ' + Buffer.byteLength(stubContents) + '\x00') + .update(stubContents, 'utf8') + .digest('hex') + + const stub = { + id: file.id, + pathname: file.pathname, + contents: stubContents, + hash: stubHash, + } + + const fileStoreRequest = MockFileStore() + .get(`/project/${this.projectId}/file/${file.id}`) + .reply(200, file.contents) + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${stub.hash}`, stub.contents) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddFileUpdate(stub, this.userId, this.timestamp, stub.hash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddFileUpdate( + historyId, + file, + this.userId, + this.timestamp, + this.projectId + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + addFile.isDone(), + `/api/projects/${historyId}/latest/files should have been called` + ) + assert( + createBlob.isDone(), + `/api/projects/${historyId}/latest/files should have been called` + ) + assert( + fileStoreRequest.isDone(), + `/project/${this.projectId}/file/${file.id} should have been called` + ) + done() + } + ) + }) + + it('should ignore comment ops', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(this.doc, this.userId, this.timestamp, [3, '\nc', 2]), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [ + { p: 3, i: '\nc' }, + { p: 3, c: '\nc' }, + ] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 2, + this.timestamp, + [{ p: 2, c: 'b' }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should be able to process lots of updates in batches', function (done) { + const BATCH_SIZE = 500 + const createFirstChangeBatch = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + ['a'.repeat(BATCH_SIZE), 6], + BATCH_SIZE - 1 + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + const createSecondChangeBatch = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + ['a'.repeat(50), BATCH_SIZE + 6], + BATCH_SIZE - 1 + 50 + ), + ]) + return true + }) + .query({ end_version: 500 }) + .reply(204) + // these need mocking again for the second batch + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: BATCH_SIZE, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + + const pushChange = (n, cb) => { + this.doc.length += 1 + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, this.userId, n, this.timestamp, [ + { p: 0, i: 'a' }, + ]), + cb + ) + } + + async.series( + [ + cb => { + async.times(BATCH_SIZE + 50, pushChange, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createFirstChangeBatch.isDone(), + `/api/projects/${historyId}/changes should have been called for the first batch` + ) + assert( + createSecondChangeBatch.isDone(), + `/api/projects/${historyId}/changes should have been called for the second batch` + ) + done() + } + ) + }) + }) + + describe('compressing updates', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + }) + + it('should concat adjacent text updates', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + [3, 'foobaz', 2], + 2 + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [ + { p: 3, i: 'foobar' }, + { p: 6, d: 'bar' }, + ] + ), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 2, + this.timestamp, + [{ p: 6, i: 'baz' }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should take the timestamp of the first update', function (done) { + const timestamp1 = new Date(this.timestamp) + const timestamp2 = new Date(this.timestamp.getTime() + 10000) + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + timestamp1, + [3, 'foobaz', 2], + 2 + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, this.userId, 1, timestamp1, [ + { p: 3, i: 'foo' }, + ]), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, this.userId, 2, timestamp2, [ + { p: 6, i: 'baz' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should not concat updates more than 60 seconds apart', function (done) { + const timestamp1 = new Date(this.timestamp) + const timestamp2 = new Date(this.timestamp.getTime() + 120000) + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(this.doc, this.userId, timestamp1, [3, 'foo', 2], 1), + olTextUpdate(this.doc, this.userId, timestamp2, [6, 'baz', 2], 2), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, this.userId, 1, timestamp1, [ + { p: 3, i: 'foo' }, + ]), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, this.userId, 2, timestamp2, [ + { p: 6, i: 'baz' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should not concat updates with different user_ids', function (done) { + const userId1 = ObjectId().toString() + const userId2 = ObjectId().toString() + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(this.doc, userId1, this.timestamp, [3, 'foo', 2], 1), + olTextUpdate(this.doc, userId2, this.timestamp, [6, 'baz', 2], 2), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, userId1, 1, this.timestamp, [ + { p: 3, i: 'foo' }, + ]), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, this.doc, userId2, 2, this.timestamp, [ + { p: 6, i: 'baz' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should not concat updates with different docs', function (done) { + const doc1 = { + id: ObjectId().toString(), + pathname: '/doc1.tex', + length: 10, + } + const doc2 = { + id: ObjectId().toString(), + pathname: '/doc2.tex', + length: 10, + } + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(doc1, this.userId, this.timestamp, [3, 'foo', 7], 1), + olTextUpdate(doc2, this.userId, this.timestamp, [6, 'baz', 4], 2), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, doc1, this.userId, 1, this.timestamp, [ + { p: 3, i: 'foo' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, doc2, this.userId, 2, this.timestamp, [ + { p: 6, i: 'baz' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should not send updates without any ops', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + // These blank ops can get sent by doc-updater on setDocs from Dropbox that don't change anything + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + !createChange.isDone(), + `/api/projects/${historyId}/changes should not have been called` + ) + done() + } + ) + }) + + it('should not send ops that compress to nothing', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [{ i: 'foo', p: 3 }] + ), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 2, + this.timestamp, + [{ d: 'foo', p: 3 }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + !createChange.isDone(), + `/api/projects/${historyId}/changes should not have been called` + ) + done() + } + ) + }) + + it('should not send ops from a diff that are blank', function (done) { + this.doc.length = 300 + // Test case taken from a real life document where it was generating blank insert and + // delete ops from a diff, and the blank delete was erroring on the OL history from + // a text operation like [42, 0, 512], where the 0 was invalid. + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdates(this.doc, this.userId, this.timestamp, [ + [ + 87, + -1, + 67, + '|l|ll|}\n\\hline', + -4, + 30, + ' \\hline', + 87, + ' \\\\ \\hline', + 24, + ], + ]), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [ + { + p: 73, + d: '\\begin{table}[h]\n\\centering\n\\caption{My caption}\n\\label{my-label}\n\\begin{tabular}{lll}\n & A & B \\\\\nLiter t up & 2 & 1 \\\\\nLiter Whiskey & 1 & 2 \\\\\nPris pr. liter & 200 & 250\n\\end{tabular}\n\\end{table}', + }, + { + p: 73, + i: '\\begin{table}[]\n\\centering\n\\caption{My caption}\n\\label{my-label}\n\\begin{tabular}{|l|ll|}\n\\hline\n & A & B \\\\ \\hline\nLiter t up & 2 & 1 \\\\\nLiter Whiskey & 1 & 2 \\\\\nPris pr. liter & 200 & 250 \\\\ \\hline\n\\end{tabular}\n\\end{table}', + }, + ] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should not concat text updates across project structure ops', function (done) { + const newDoc = { + id: ObjectId().toString(), + pathname: '/main.tex', + hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb', + docLines: 'a\nb', + } + + MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${newDoc.hash}`) + .reply(201) + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + [3, 'foo', 2], + 1 + ), + olAddDocUpdate(newDoc, this.userId, this.timestamp, newDoc.hash), + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + [6, 'baz', 2], + 2 + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [ + { p: 3, i: 'foobar' }, + { p: 6, d: 'bar' }, + ] + ), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdate( + historyId, + newDoc, + this.userId, + this.timestamp, + newDoc.docLines + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 2, + this.timestamp, + [{ p: 6, i: 'baz' }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should track the doc length when splitting ops', function (done) { + this.doc.length = 10 + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate(this.doc, this.userId, this.timestamp, [3, -3, 4], 1), + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + [3, 'barbaz', 4], + 2 + ), // This has a base length of 10 + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 1, + this.timestamp, + [ + { p: 3, d: 'foo' }, + { p: 3, i: 'bar' }, // Make sure the length of the op generated from this is 7, not 10 + ] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 2, + this.timestamp, + [{ p: 6, i: 'baz' }] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe('with bad pathnames', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + }) + + it('should replace \\ with _ and workaround * in pathnames', function (done) { + const doc = { + id: this.doc.id, + pathname: '\\main.tex', + hash: 'b07b6b7a27667965f733943737124395c7577bea', + docLines: 'aaabbbccc', + length: 9, + } + + MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${doc.hash}`) + .reply(201) + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddDocUpdate( + { id: doc.id, pathname: '_main.tex' }, + this.userId, + this.timestamp, + doc.hash + ), + olRenameUpdate( + { id: doc.id, pathname: '_main.tex' }, + this.userId, + this.timestamp, + '_main.tex', + '_main2.tex' + ), + olTextUpdate( + { id: doc.id, pathname: '_main2.tex' }, + this.userId, + this.timestamp, + [3, 'foo', 6], + 2 + ), + olRenameUpdate( + { id: doc.id, pathname: '_main2.tex' }, + this.userId, + this.timestamp, + '_main2.tex', + '_main__ASTERISK__.tex' + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdate( + historyId, + doc, + this.userId, + this.timestamp, + doc.docLines + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slRenameUpdate( + historyId, + doc, + this.userId, + this.timestamp, + '/\\main.tex', + '/\\main2.tex' + ), + cb + ) + doc.pathname = '\\main2.tex' + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate(historyId, doc, this.userId, 2, this.timestamp, [ + { p: 3, i: 'foo' }, + ]), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slRenameUpdate( + historyId, + doc, + this.userId, + this.timestamp, + '/\\main2.tex', + '/\\main*.tex' + ), + cb + ) + doc.pathname = '\\main*.tex' + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should workaround pathnames beginning with spaces', function (done) { + const doc = { + id: this.doc.id, + pathname: 'main.tex', + hash: 'b07b6b7a27667965f733943737124395c7577bea', + docLines: 'aaabbbccc', + length: 9, + } + + MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${doc.hash}`) + .reply(201) + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddDocUpdate( + { id: doc.id, pathname: 'main.tex' }, + this.userId, + this.timestamp, + doc.hash + ), + olRenameUpdate( + { id: doc.id }, + this.userId, + this.timestamp, + 'main.tex', + 'foo/__SPACE__main.tex' + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdate( + historyId, + doc, + this.userId, + this.timestamp, + doc.docLines + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slRenameUpdate( + historyId, + doc, + this.userId, + this.timestamp, + '/main.tex', + '/foo/ main.tex' + ), + cb + ) + doc.pathname = '/foo/ main.tex' + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe('with bad response from filestore', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: {}, + changes: [], + }, + }, + }) + }) + + it('should return a 500 if the filestore returns a 500', function (done) { + const file = { + id: ObjectId().toString(), + pathname: '/test.png', + contents: Buffer.from([1, 2, 3]), + hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74', + } + + const fileStoreRequest = MockFileStore() + .get(`/project/${this.projectId}/file/${file.id}`) + .reply(500) + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddFileUpdate(file, this.userId, this.timestamp, file.hash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddFileUpdate( + historyId, + file, + this.userId, + this.timestamp, + this.projectId + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject( + this.projectId, + { allowErrors: true }, + (error, res) => { + if (error) { + return cb(error) + } + expect(res.statusCode).to.equal(500) + cb() + } + ) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + fileStoreRequest.isDone(), + `/project/${this.projectId}/file/${file.id} should have been called` + ) + assert( + !createBlob.isDone(), + `/api/projects/${historyId}/latest/files should not have been called` + ) + assert( + !addFile.isDone(), + `/api/projects/${historyId}/latest/files should not have been called` + ) + done() + } + ) + }) + + it('should return a 500 if the filestore request errors', function (done) { + const file = { + id: ObjectId().toString(), + pathname: '/test.png', + contents: Buffer.from([1, 2, 3]), + hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74', + } + + const fileStoreRequest = MockFileStore() + .get(`/project/${this.projectId}/file/${file.id}`) + .replyWithError('oh no!') + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddFileUpdate(file, this.userId, this.timestamp, file.hash), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddFileUpdate( + historyId, + file, + this.userId, + this.timestamp, + this.projectId + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject( + this.projectId, + { allowErrors: true }, + (error, res) => { + if (error) { + return cb(error) + } + expect(res.statusCode).to.equal(500) + cb() + } + ) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + fileStoreRequest.isDone(), + `/project/${this.projectId}/file/${file.id} should have been called` + ) + assert( + !createBlob.isDone(), + `/api/projects/${historyId}/latest/files should not have been called` + ) + assert( + !addFile.isDone(), + `/api/projects/${historyId}/latest/files should not have been called` + ) + done() + } + ) + }) + }) + + describe('with an existing projectVersion field', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: { projectVersion: '100.0' }, + changes: [], + }, + }, + }) + }) + + it('should discard project structure updates which have already been applied', function (done) { + const newDoc = [] + for (let i = 0; i <= 2; i++) { + newDoc[i] = { + id: ObjectId().toString(), + pathname: `/main${i}.tex`, + hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb', + docLines: 'a\nb', + } + } + + MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${newDoc[0].hash}`) + .times(3) + .reply(201) + + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olAddDocUpdateWithVersion( + newDoc[1], + this.userId, + this.timestamp, + newDoc[1].hash, + '101.0' + ), + olAddDocUpdateWithVersion( + newDoc[2], + this.userId, + this.timestamp, + newDoc[2].hash, + '102.0' + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdateWithVersion( + historyId, + newDoc[0], + this.userId, + this.timestamp, + newDoc[0].docLines, + '100.0' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdateWithVersion( + historyId, + newDoc[1], + this.userId, + this.timestamp, + newDoc[1].docLines, + '101.0' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slAddDocUpdateWithVersion( + historyId, + newDoc[2], + this.userId, + this.timestamp, + newDoc[2].docLines, + '102.0' + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe('with an existing docVersions field', function () { + beforeEach(function () { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + snapshot: { v2DocVersions: { [this.doc.id]: { v: 100 } } }, // version 100 below already applied + changes: [], + }, + }, + }) + }) + + it('should discard doc updates which have already been applied', function (done) { + const createChange = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + olTextUpdate( + this.doc, + this.userId, + this.timestamp, + [6, 'baz', 2], + 101 + ), + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 100, + this.timestamp, + [ + { p: 3, i: 'foobar' }, // these ops should be skipped + { p: 6, d: 'bar' }, + ] + ), + cb + ) + this.doc.length += 3 + }, + cb => { + ProjectHistoryClient.pushRawUpdate( + this.projectId, + slTextUpdate( + historyId, + this.doc, + this.userId, + 101, + this.timestamp, + [ + { p: 6, i: 'baz' }, // this op should be applied + ] + ), + cb + ) + }, + cb => { + ProjectHistoryClient.flushProject(this.projectId, cb) + }, + ], + error => { + if (error) { + return done(error) + } + assert( + createChange.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/SummarisedUpdatesTests.js b/services/project-history/test/acceptance/js/SummarisedUpdatesTests.js new file mode 100644 index 0000000000..3feab5027d --- /dev/null +++ b/services/project-history/test/acceptance/js/SummarisedUpdatesTests.js @@ -0,0 +1,249 @@ +/* eslint-disable + camelcase, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import Settings from '@overleaf/settings' +import request from 'request' +import assert from 'assert' +import { ObjectId } from 'mongodb' +import nock from 'nock' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockFileStore = () => nock('http://localhost:3009') +const MockWeb = () => nock('http://localhost:3000') + +const fixture = path => new URL(`../fixtures/${path}`, import.meta.url) + +describe('Summarized updates', function () { + beforeEach(function (done) { + this.projectId = ObjectId().toString() + this.historyId = ObjectId().toString() + return ProjectHistoryApp.ensureRunning(error => { + if (error != null) { + throw error + } + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: this.historyId, + }) + + return ProjectHistoryClient.initializeProject( + this.historyId, + (error, ol_project) => { + if (error != null) { + throw error + } + MockWeb() + .get(`/project/${this.projectId}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { history: { id: ol_project.id } }, + }) + + MockHistoryStore() + .get(`/api/projects/${this.historyId}/latest/history`) + .replyWithFile(200, fixture('chunks/7-8.json')) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/3/history`) + .replyWithFile(200, fixture('chunks/0-3.json')) + + return done() + } + ) + }) + }) + + afterEach(function () { + return nock.cleanAll() + }) + + it('should return the latest summarized updates from a single chunk', function (done) { + return ProjectHistoryClient.getSummarizedUpdates( + this.projectId, + { min_count: 1 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates).to.deep.equal({ + nextBeforeTimestamp: 6, + updates: [ + { + fromV: 6, + toV: 8, + meta: { + users: ['5a5637efdac84e81b71014c4', 31], + start_ts: 1512383567277, + end_ts: 1512383572877, + }, + pathnames: ['bar.tex', 'main.tex'], + project_ops: [], + labels: [], + }, + ], + }) + return done() + } + ) + }) + + it('should return the latest summarized updates, with min_count spanning multiple chunks', function (done) { + return ProjectHistoryClient.getSummarizedUpdates( + this.projectId, + { min_count: 5 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates).to.deep.equal({ + updates: [ + { + fromV: 6, + toV: 8, + meta: { + users: ['5a5637efdac84e81b71014c4', 31], + start_ts: 1512383567277, + end_ts: 1512383572877, + }, + pathnames: ['bar.tex', 'main.tex'], + project_ops: [], + labels: [], + }, + { + fromV: 5, + toV: 6, + meta: { + users: [31], + start_ts: 1512383366120, + end_ts: 1512383366120, + }, + pathnames: [], + project_ops: [ + { + atV: 5, + rename: { + pathname: 'foo.tex', + newPathname: 'bar.tex', + }, + }, + ], + labels: [], + }, + { + fromV: 2, + toV: 5, + meta: { + users: [31], + start_ts: 1512383313724, + end_ts: 1512383362905, + }, + pathnames: ['foo.tex'], + project_ops: [], + labels: [], + }, + { + fromV: 1, + toV: 2, + meta: { + users: [31], + start_ts: 1512383246874, + end_ts: 1512383246874, + }, + pathnames: [], + project_ops: [ + { + atV: 1, + rename: { + pathname: 'bar.tex', + newPathname: 'foo.tex', + }, + }, + ], + labels: [], + }, + { + fromV: 0, + toV: 1, + meta: { + users: [31], + start_ts: 1512383015633, + end_ts: 1512383015633, + }, + pathnames: ['main.tex'], + project_ops: [], + labels: [], + }, + ], + }) + return done() + } + ) + }) + + it('should return the summarized updates from a before version at the start of a chunk', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/4/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + return ProjectHistoryClient.getSummarizedUpdates( + this.projectId, + { before: 4 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates.updates[0].toV).to.equal(4) + return done() + } + ) + }) + + it('should return the summarized updates from a before version in the middle of a chunk', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/5/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + return ProjectHistoryClient.getSummarizedUpdates( + this.projectId, + { before: 5 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates.updates[0].toV).to.equal(5) + return done() + } + ) + }) + + return it('should return the summarized updates from a before version at the end of a chunk', function (done) { + MockHistoryStore() + .get(`/api/projects/${this.historyId}/versions/6/history`) + .replyWithFile(200, fixture('chunks/4-6.json')) + return ProjectHistoryClient.getSummarizedUpdates( + this.projectId, + { before: 6 }, + (error, updates) => { + if (error != null) { + throw error + } + expect(updates.updates[0].toV).to.equal(6) + return done() + } + ) + }) +}) diff --git a/services/project-history/test/acceptance/js/SyncTests.js b/services/project-history/test/acceptance/js/SyncTests.js new file mode 100644 index 0000000000..abe99632e5 --- /dev/null +++ b/services/project-history/test/acceptance/js/SyncTests.js @@ -0,0 +1,556 @@ +import async from 'async' +import nock from 'nock' +import { expect } from 'chai' +import request from 'request' +import assert from 'assert' +import { ObjectId } from 'mongodb' +import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' +import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' + +const EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' + +const MockHistoryStore = () => nock('http://localhost:3100') +const MockFileStore = () => nock('http://localhost:3009') +const MockWeb = () => nock('http://localhost:3000') + +describe('Syncing with web and doc-updater', function () { + const historyId = ObjectId().toString() + + beforeEach(function (done) { + this.timestamp = new Date() + + ProjectHistoryApp.ensureRunning(error => { + if (error) { + throw error + } + this.project_id = ObjectId().toString() + this.doc_id = ObjectId().toString() + this.file_id = ObjectId().toString() + + MockHistoryStore().post('/api/projects').reply(200, { + projectId: historyId, + }) + MockWeb() + .get(`/project/${this.project_id}/details`) + .reply(200, { + name: 'Test Project', + overleaf: { + history: { + id: historyId, + }, + }, + }) + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + startVersion: 0, + history: { + changes: [], + }, + }, + }) + ProjectHistoryClient.initializeProject(historyId, done) + }) + }) + + afterEach(function () { + nock.cleanAll() + }) + + describe('resyncing project history', function () { + describe('without project-history enabled', function () { + beforeEach(function () { + MockWeb().post(`/project/${this.project_id}/history/resync`).reply(404) + }) + + it('404s if project-history is not enabled', function (done) { + request.post( + { + url: `http://localhost:3054/project/${this.project_id}/resync`, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(404) + done() + } + ) + }) + }) + + describe('with project-history enabled', function () { + beforeEach(function () { + MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204) + }) + + describe('when a doc is missing', function () { + it('should send add doc updates to the history store', function (done) { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + persistedDoc: { hash: EMPTY_FILE_HASH, stringLength: 0 }, + }, + }, + changes: [], + }, + startVersion: 0, + }, + }) + + MockHistoryStore() + .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`) + .reply(200, '') + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`, '') + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + { + v2Authors: [], + authors: [], + timestamp: this.timestamp.toJSON(), + operations: [ + { + pathname: 'main.tex', + file: { + hash: EMPTY_FILE_HASH, + }, + }, + ], + origin: { kind: 'test-origin' }, + }, + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.resyncHistory(this.project_id, cb) + }, + cb => { + const update = { + projectHistoryId: historyId, + resyncProjectStructure: { + docs: [ + { path: '/main.tex', doc: this.doc_id }, + { path: '/persistedDoc', doc: 'other-doc-id' }, + ], + files: [], + }, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error) { + throw error + } + assert( + createBlob.isDone(), + '/api/projects/:historyId/blobs/:hash should have been called' + ) + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe('when a file is missing', function () { + it('should send add file updates to the history store', function (done) { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 }, + }, + }, + changes: [], + }, + startVersion: 0, + }, + }) + + const fileContents = Buffer.from([1, 2, 3]) + const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74' + + MockFileStore() + .get(`/project/${this.project_id}/file/${this.file_id}`) + .reply(200, fileContents) + + const createBlob = MockHistoryStore() + .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents) + .reply(201) + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + { + v2Authors: [], + authors: [], + timestamp: this.timestamp.toJSON(), + operations: [ + { + pathname: 'test.png', + file: { + hash: fileHash, + }, + }, + ], + origin: { kind: 'test-origin' }, + }, + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.resyncHistory(this.project_id, cb) + }, + cb => { + const update = { + projectHistoryId: historyId, + resyncProjectStructure: { + docs: [], + files: [ + { + file: this.file_id, + path: '/test.png', + url: `http://localhost:3009/project/${this.project_id}/file/${this.file_id}`, + }, + { path: '/persistedFile' }, + ], + }, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error) { + throw error + } + assert( + createBlob.isDone(), + '/api/projects/:historyId/blobs/:hash should have been called' + ) + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe("when a file exists which shouldn't", function () { + it('should send remove file updates to the history store', function (done) { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + docToKeep: { hash: EMPTY_FILE_HASH, stringLength: 0 }, + docToDelete: { hash: EMPTY_FILE_HASH, stringLength: 0 }, + }, + }, + changes: [], + }, + startVersion: 0, + }, + }) + + MockHistoryStore() + .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`) + .reply(200, '') + .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`) + .reply(200, '') // blob is requested once for each file + + const deleteFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + { + v2Authors: [], + authors: [], + timestamp: this.timestamp.toJSON(), + operations: [ + { + pathname: 'docToDelete', + newPathname: '', + }, + ], + origin: { kind: 'test-origin' }, + }, + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.resyncHistory(this.project_id, cb) + }, + cb => { + const update = { + projectHistoryId: historyId, + resyncProjectStructure: { + docs: [{ path: 'docToKeep' }], + files: [], + }, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error) { + throw error + } + assert( + deleteFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + + describe("when a doc's contents is not up to date", function () { + it('should send test updates to the history store', function (done) { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'main.tex': { + hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb', + stringLength: 3, + }, + }, + }, + changes: [], + }, + startVersion: 0, + }, + }) + + MockHistoryStore() + .get( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(200, 'a\nb') + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + { + v2Authors: [], + authors: [], + timestamp: this.timestamp.toJSON(), + operations: [ + { + pathname: 'main.tex', + textOperation: [3, '\nc'], + }, + ], + origin: { kind: 'test-origin' }, + }, + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.resyncHistory(this.project_id, cb) + }, + cb => { + const update = { + projectHistoryId: historyId, + resyncProjectStructure: { + docs: [{ path: '/main.tex' }], + files: [], + }, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + const update = { + path: '/main.tex', + projectHistoryId: historyId, + resyncDocContent: { + content: 'a\nb\nc', + }, + doc: this.doc_id, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error) { + throw error + } + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + + it('should strip non-BMP characters in updates before sending to the history store', function (done) { + MockHistoryStore() + .get(`/api/projects/${historyId}/latest/history`) + .reply(200, { + chunk: { + history: { + snapshot: { + files: { + 'main.tex': { + hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb', + stringLength: 3, + }, + }, + }, + changes: [], + }, + startVersion: 0, + }, + }) + + MockHistoryStore() + .get( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(200, 'a\nb') + + const addFile = MockHistoryStore() + .post(`/api/projects/${historyId}/legacy_changes`, body => { + expect(body).to.deep.equal([ + { + v2Authors: [], + authors: [], + timestamp: this.timestamp.toJSON(), + operations: [ + { + pathname: 'main.tex', + textOperation: [3, '\n\uFFFD\uFFFDc'], + }, + ], + origin: { kind: 'test-origin' }, + }, + ]) + return true + }) + .query({ end_version: 0 }) + .reply(204) + + async.series( + [ + cb => { + ProjectHistoryClient.resyncHistory(this.project_id, cb) + }, + cb => { + const update = { + projectHistoryId: historyId, + resyncProjectStructure: { + docs: [{ path: '/main.tex' }], + files: [], + }, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + const update = { + path: '/main.tex', + projectHistoryId: historyId, + resyncDocContent: { + content: 'a\nb\n\uD800\uDC00c', + }, + doc: this.doc_id, + meta: { + ts: this.timestamp, + }, + } + ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb) + }, + cb => { + ProjectHistoryClient.flushProject(this.project_id, cb) + }, + ], + error => { + if (error) { + throw error + } + assert( + addFile.isDone(), + `/api/projects/${historyId}/changes should have been called` + ) + done() + } + ) + }) + }) + }) + }) +}) diff --git a/services/project-history/test/acceptance/js/helpers/HistoryId.js b/services/project-history/test/acceptance/js/helpers/HistoryId.js new file mode 100644 index 0000000000..198083123a --- /dev/null +++ b/services/project-history/test/acceptance/js/helpers/HistoryId.js @@ -0,0 +1,7 @@ +// TODO: This file was created by bulk-decaffeinate. +// Sanity-check the conversion and remove this comment. +let id = 0 + +export function nextId() { + return id++ +} diff --git a/services/project-history/test/acceptance/js/helpers/HistoryStoreClient.js b/services/project-history/test/acceptance/js/helpers/HistoryStoreClient.js new file mode 100644 index 0000000000..93eca1d277 --- /dev/null +++ b/services/project-history/test/acceptance/js/helpers/HistoryStoreClient.js @@ -0,0 +1,42 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { expect } from 'chai' +import request from 'request' +import Settings from '@overleaf/settings' + +export function getLatestContent(ol_project_id, callback) { + if (callback == null) { + callback = function () {} + } + return request.get( + { + url: `${Settings.overleaf.history.host}/projects/${ol_project_id}/latest/content`, + auth: { + user: Settings.overleaf.history.user, + pass: Settings.overleaf.history.pass, + sendImmediately: true, + }, + }, + (error, res, body) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + callback( + new Error( + `history store a non-success status code: ${res.statusCode}` + ) + ) + } + + return callback(error, JSON.parse(body)) + } + ) +} diff --git a/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js b/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js new file mode 100644 index 0000000000..6198e0d336 --- /dev/null +++ b/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js @@ -0,0 +1,43 @@ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { app } from '../../../../app/js/server.js' +import logger from '@overleaf/logger' +logger.logger.level('error') + +let running = false +let initing = false +const callbacks = [] + +export function ensureRunning(callback) { + if (callback == null) { + callback = function () {} + } + if (running) { + return callback() + } else if (initing) { + return callbacks.push(callback) + } + initing = true + callbacks.push(callback) + app.listen(3054, 'localhost', error => { + if (error != null) { + throw error + } + running = true + return (() => { + const result = [] + for (callback of Array.from(callbacks)) { + result.push(callback()) + } + return result + })() + }) +} diff --git a/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js new file mode 100644 index 0000000000..3a8e06efc5 --- /dev/null +++ b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js @@ -0,0 +1,300 @@ +import { expect } from 'chai' +import request from 'request' +import Settings from '@overleaf/settings' +import RedisWrapper from '@overleaf/redis-wrapper' +import { db } from '../../../../app/js/mongodb.js' + +const rclient = RedisWrapper.createClient(Settings.redis.project_history) +const Keys = Settings.redis.project_history.key_schema + +export function resetDatabase(callback) { + rclient.flushdb(callback) +} + +export function initializeProject(historyId, callback) { + request.post( + { + url: 'http://localhost:3054/project', + json: { historyId }, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + callback(null, body.project) + } + ) +} + +export function flushProject(projectId, options, callback) { + if (typeof options === 'function') { + callback = options + options = null + } + if (!options) { + options = { allowErrors: false } + } + request.post( + { + url: `http://localhost:3054/project/${projectId}/flush`, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + if (!options.allowErrors) { + expect(res.statusCode).to.equal(204) + } + callback(error, res) + } + ) +} + +export function getSummarizedUpdates(projectId, query, callback) { + request.get( + { + url: `http://localhost:3054/project/${projectId}/updates`, + qs: query, + json: true, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + callback(error, body) + } + ) +} + +export function getDiff(projectId, pathname, from, to, callback) { + request.get( + { + url: `http://localhost:3054/project/${projectId}/diff`, + qs: { + pathname, + from, + to, + }, + json: true, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + callback(error, body) + } + ) +} + +export function getFileTreeDiff(projectId, from, to, callback) { + request.get( + { + url: `http://localhost:3054/project/${projectId}/filetree/diff`, + qs: { + from, + to, + }, + json: true, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + callback(error, body, res.statusCode) + } + ) +} + +export function getSnapshot(projectId, pathname, version, options, callback) { + if (typeof options === 'function') { + callback = options + options = null + } + if (!options) { + options = { allowErrors: false } + } + request.get( + { + url: `http://localhost:3054/project/${projectId}/version/${version}/${encodeURIComponent( + pathname + )}`, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + if (!options.allowErrors) { + expect(res.statusCode).to.equal(200) + } + callback(error, body, res.statusCode) + } + ) +} + +export function pushRawUpdate(projectId, update, callback) { + rclient.rpush( + Keys.projectHistoryOps({ project_id: projectId }), + JSON.stringify(update), + callback + ) +} + +export function setFirstOpTimestamp(projectId, timestamp, callback) { + rclient.set( + Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }), + timestamp, + callback + ) +} + +export function getFirstOpTimestamp(projectId, callback) { + rclient.get( + Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }), + callback + ) +} + +export function clearFirstOpTimestamp(projectId, callback) { + rclient.del( + Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }), + callback + ) +} + +export function getQueueLength(projectId, callback) { + rclient.llen(Keys.projectHistoryOps({ project_id: projectId }), callback) +} + +export function getQueueCounts(callback) { + return request.get( + { + url: 'http://localhost:3054/status/queue', + json: true, + }, + callback + ) +} + +export function resyncHistory(projectId, callback) { + request.post( + { + url: `http://localhost:3054/project/${projectId}/resync`, + json: true, + body: { origin: { kind: 'test-origin' } }, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(204) + callback(error) + } + ) +} + +export function createLabel( + projectId, + userId, + version, + comment, + createdAt, + callback +) { + request.post( + { + url: `http://localhost:3054/project/${projectId}/user/${userId}/labels`, + json: { comment, version, created_at: createdAt }, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + callback(null, body) + } + ) +} + +export function getLabels(projectId, callback) { + request.get( + { + url: `http://localhost:3054/project/${projectId}/labels`, + json: true, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(200) + callback(null, body) + } + ) +} + +export function deleteLabel(projectId, userId, labelId, callback) { + request.delete( + { + url: `http://localhost:3054/project/${projectId}/user/${userId}/labels/${labelId}`, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(204) + callback(null, body) + } + ) +} + +export function setFailure(failureEntry, callback) { + db.projectHistoryFailures.remove( + { project_id: { $exists: true } }, + (err, result) => { + if (err) { + return callback(err) + } + db.projectHistoryFailures.insert(failureEntry, callback) + } + ) +} + +export function transferLabelOwnership(fromUser, toUser, callback) { + request.post( + { + url: `http://localhost:3054/user/${fromUser}/labels/transfer/${toUser}`, + }, + (error, res, body) => { + if (error) { + return callback(error) + } + expect(res.statusCode).to.equal(204) + callback(null, body) + } + ) +} + +export function getDump(projectId, callback) { + request.get( + `http://localhost:3054/project/${projectId}/dump`, + (err, res, body) => { + if (err) { + return callback(err) + } + expect(res.statusCode).to.equal(200) + callback(null, JSON.parse(body)) + } + ) +} + +export function deleteProject(projectId, callback) { + request.delete(`http://localhost:3054/project/${projectId}`, (err, res) => { + if (err) { + return callback(err) + } + expect(res.statusCode).to.equal(204) + callback() + }) +} diff --git a/services/project-history/test/setup.js b/services/project-history/test/setup.js new file mode 100644 index 0000000000..f011481be3 --- /dev/null +++ b/services/project-history/test/setup.js @@ -0,0 +1,6 @@ +import chai from 'chai' +import sinonChai from 'sinon-chai' + +// Chai configuration +chai.should() +chai.use(sinonChai) diff --git a/services/project-history/test/unit/js/BlobManager/BlobManagerTests.js b/services/project-history/test/unit/js/BlobManager/BlobManagerTests.js new file mode 100644 index 0000000000..5c4a1ca2cd --- /dev/null +++ b/services/project-history/test/unit/js/BlobManager/BlobManagerTests.js @@ -0,0 +1,156 @@ +import sinon from 'sinon' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/BlobManager.js' + +describe('BlobManager', function () { + beforeEach(async function () { + this.callback = sinon.stub() + this.extendLock = sinon.stub().yields() + this.project_id = 'project-1' + this.historyId = 12345 + this.HistoryStoreManager = { + createBlobForUpdate: sinon.stub(), + } + this.UpdateTranslator = { + isAddUpdate: sinon.stub().returns(false), + } + this.BlobManager = await esmock(MODULE_PATH, { + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator, + }) + this.updates = ['update-1', 'update-2'] + }) + + describe('createBlobsForUpdates', function () { + describe('when there are no blobs to create', function () { + beforeEach(function (done) { + this.BlobManager.createBlobsForUpdates( + this.project_id, + this.historyId, + this.updates, + this.extendLock, + (error, updatesWithBlobs) => { + this.callback(error, updatesWithBlobs) + done() + } + ) + }) + + it('should not create any blobs', function () { + this.HistoryStoreManager.createBlobForUpdate.called.should.equal(false) + }) + + it('should call the callback with the updates', function () { + const updatesWithBlobs = this.updates.map(update => ({ + update, + })) + this.callback.calledWith(null, updatesWithBlobs).should.equal(true) + }) + }) + + describe('when there are blobs to create', function () { + beforeEach(function (done) { + this.UpdateTranslator.isAddUpdate.returns(true) + this.blobHash = 'test hash' + this.HistoryStoreManager.createBlobForUpdate.yields(null, this.blobHash) + this.BlobManager.createBlobsForUpdates( + this.project_id, + this.historyId, + this.updates, + this.extendLock, + (error, updatesWithBlobs) => { + this.callback(error, updatesWithBlobs) + done() + } + ) + }) + + it('should create blobs', function () { + this.HistoryStoreManager.createBlobForUpdate + .calledWith(this.project_id, this.historyId, this.updates[0]) + .should.equal(true) + }) + + it('should extend the lock', function () { + this.extendLock.called.should.equal(true) + }) + + it('should call the callback with the updates', function () { + const updatesWithBlobs = this.updates.map(update => ({ + update, + blobHash: this.blobHash, + })) + this.callback.calledWith(null, updatesWithBlobs).should.equal(true) + }) + }) + + describe('when there are blobs to create and there is a single network error', function () { + beforeEach(function (done) { + this.UpdateTranslator.isAddUpdate.returns(true) + this.blobHash = 'test hash' + this.HistoryStoreManager.createBlobForUpdate + .onFirstCall() + .yields(new Error('random failure')) + this.HistoryStoreManager.createBlobForUpdate.yields(null, this.blobHash) + this.BlobManager.createBlobsForUpdates( + this.project_id, + this.historyId, + this.updates, + this.extendLock, + (error, updatesWithBlobs) => { + this.callback(error, updatesWithBlobs) + done() + } + ) + }) + + it('should create blobs', function () { + this.HistoryStoreManager.createBlobForUpdate + .calledWith(this.project_id, this.historyId, this.updates[0]) + .should.equal(true) + }) + + it('should extend the lock', function () { + this.extendLock.called.should.equal(true) + }) + + it('should call the callback with the updates', function () { + const updatesWithBlobs = this.updates.map(update => ({ + update, + blobHash: this.blobHash, + })) + this.callback.calledWith(null, updatesWithBlobs).should.equal(true) + }) + }) + + describe('when there are blobs to create and there are multiple network errors', function () { + beforeEach(function (done) { + this.UpdateTranslator.isAddUpdate.returns(true) + this.blobHash = 'test hash' + this.error = new Error('random failure') + this.HistoryStoreManager.createBlobForUpdate.yields(this.error) + this.BlobManager.createBlobsForUpdates( + this.project_id, + this.historyId, + this.updates, + this.extendLock, + (error, updatesWithBlobs) => { + this.callback(error, updatesWithBlobs) + done() + } + ) + }) + + it('should try to create blobs', function () { + this.HistoryStoreManager.createBlobForUpdate + .calledWith(this.project_id, this.historyId, this.updates[0]) + .should.equal(true) + }) + + it('should call the callback with an error', function () { + this.callback.calledWith(this.error).should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js b/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js new file mode 100644 index 0000000000..0d258413ee --- /dev/null +++ b/services/project-history/test/unit/js/ChunkTranslator/ChunkTranslatorTests.js @@ -0,0 +1,1734 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/ChunkTranslator.js' + +describe('ChunkTranslator', function () { + beforeEach(async function () { + this.projectId = '0123456789abc0123456789abc' + this.historyId = 12345 + this.author1 = { + id: 1, + email: 'james.allen@overleaf.com', + name: 'James Allen', + } + this.date = new Date() + this.fileHash = 'some_hash' + this.fileContents = 'Hello world, this is a test' + this.HistoryStoreManager = { + getProjectBlob: sinon.stub(), + } + this.HistoryStoreManager.getProjectBlob + .withArgs(this.historyId, this.fileHash) + .yields(null, this.fileContents) + this.WebApiManager = { + getHistoryId: sinon.stub().callsFake((projectId, cb) => { + console.log({ projectId }) + }), + } + this.WebApiManager.getHistoryId + .withArgs(this.projectId) + .yields(null, this.historyId) + this.ChunkTranslator = await esmock(MODULE_PATH, { + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + }) + this.callback = sinon.stub() + }) + + describe('with changes to the text', function () { + beforeEach(function () { + this.chunk = { + project_id: this.projectId, + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: ['Hello test, ', -6] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'main.tex', textOperation: [6, 'foo '] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [{ pathname: 'main.tex', textOperation: [6, -4] }], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + }) + + describe('convertToDiffUpdates', function () { + it('should convert them to insert and delete ops', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 3, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [ + { i: 'Hello test, ', p: 0 }, + { d: 'Hello ', p: 12 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + op: [{ i: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + op: [{ d: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + ]) + done() + } + ) + }) + + it('should return the correct initial text if there are previous changes', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 2, + 3, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal( + 'Hello foo test, world, this is a test' + ) + expect(updates).to.deep.equal([ + { + op: [{ d: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary of which docs changes when', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + + expect(updates).to.deep.equal([ + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('with a sequence of inserts and deletes', function () { + beforeEach(function () { + this.fileHash = 'some_other_hash' + this.initialFileContents = 'aa bbbbb ccc ' + this.HistoryStoreManager.getProjectBlob + .withArgs(this.historyId, this.fileHash) + .yields(null, this.initialFileContents) + + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [ + '111 ', // -> "111 aa bbbbb ccc " + -3, // -> "111 bbbbb ccc " + 6, // -> "111 bbbbb ccc " + '2222 ', // -> "111 bbbbb 2222 ccc " + -1, // -> "111 bbbbb 2222 cc " + 'd', // -> "111 bbbbb 2222 dcc " + 3, + ], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + }) + + describe('convertToDiffUpdates', function () { + it('should convert them to insert and delete ops', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.initialFileContents) + expect(updates).to.deep.equal([ + { + op: [ + { i: '111 ', p: 0 }, + { d: 'aa ', p: 4 }, + { i: '2222 ', p: 10 }, + { d: 'c', p: 15 }, + { i: 'd', p: 15 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('should apply them to the text correctly', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 1, + 1, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('111 bbbbb 2222 dcc ') + expect(updates).to.deep.equal([]) + done() + } + ) + }) + }) + }) + + describe('with unknown operations', function (done) { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [{ unknown: true }], + timestamp: this.date.toISOString(), + authors: [this.author1.id, undefined], + }, + { + operations: [ + { pathname: 'main.tex', textOperation: [3, 'Hello world'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id, undefined], + }, + ], + }, + }, + authors: [this.author1.id], + } + }) + + describe('convertToDiffUpdates', function () { + it('should ignore the unknown update', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 2, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'Hello world', p: 3 }], + meta: { + users: [this.author1.id, null], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should ignore the unknown update', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: [], + project_ops: [], + meta: { + users: [this.author1.id, null], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id, null], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('with changes to multiple files', function (done) { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'other.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'other.tex', textOperation: [0, 'foo'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'main.tex', textOperation: [6, 'bar'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'other.tex', textOperation: [9, 'baz'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'main.tex', textOperation: [12, 'qux'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + }) + + describe('convertToDiffUpdates', function () { + it('should only return the changes to the requested file', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 4, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('Hello world, this is a test') + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + op: [{ i: 'qux', p: 12 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary of which docs changes when', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: ['other.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + pathnames: ['other.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('when the file is created during the chunk', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: [6, 'bar'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { + pathname: 'new.tex', + file: { hash: this.fileHash, stringLength: 10 }, + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'new.tex', textOperation: [6, 'bar'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'new.tex', textOperation: [9, 'baz'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToDiffUpdates', function () { + it('returns changes after the file was created before the fromVersion', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'new.tex', + 2, + 4, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 9 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + ) + }) + + it('returns changes when the file was created at the fromVersion', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'new.tex', + 1, + 4, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 9 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + ) + }) + + it('returns changes when the file was created after the fromVersion', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'new.tex', + 0, + 4, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 9 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary which includes the addition', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: [], + project_ops: [ + { + add: { + pathname: 'new.tex', + }, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + pathnames: ['new.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + pathnames: ['new.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('when the file is renamed during the chunk', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: [0, 'foo'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'main.tex', newPathname: 'moved.tex' }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'moved.tex', textOperation: [3, 'bar'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'moved.tex', newPathname: 'moved_again.tex' }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'moved_again.tex', textOperation: [6, 'baz'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToDiffUpdates', function () { + it('uses the original pathname before it is moved', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 5, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'foo', p: 0 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + op: [{ i: 'bar', p: 3 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 4, + }, + ]) + done() + } + ) + }) + + it('uses the original pathname for before the move change', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 1, + 5, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('foo' + this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 3 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 4, + }, + ]) + done() + } + ) + }) + + it('uses the new pathname for after the move change', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'moved.tex', + 2, + 5, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('foo' + this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'bar', p: 3 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + op: [{ i: 'baz', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 4, + }, + ]) + done() + } + ) + }) + + it('tracks multiple renames', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'moved_again.tex', + 4, + 5, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('foobar' + this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'baz', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 4, + }, + ]) + done() + } + ) + }) + + it('returns an error when referring to a file that is now moved', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 4, + 5, + error => { + expect(error.message).to.equal( + "pathname 'main.tex' not found in range" + ) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary which includes the rename', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: [], + project_ops: [ + { + rename: { + pathname: 'main.tex', + newPathname: 'moved.tex', + }, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + pathnames: ['moved.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + { + pathnames: [], + project_ops: [ + { + rename: { + pathname: 'moved.tex', + newPathname: 'moved_again.tex', + }, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 3, + }, + { + pathnames: ['moved_again.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 4, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('when the file is deleted during the chunk', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'other.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: [0, 'foo'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [ + { pathname: 'other.tex', textOperation: [0, 'foo'] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToDiffUpdates', function () { + it('returns updates up to when it is deleted', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 3, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [{ i: 'foo', p: 0 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + + it('returns nothing if fromVersion is when is it was deleted', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 1, + 3, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal('foo' + this.fileContents) + expect(updates).to.deep.equal([]) + done() + } + ) + }) + + it('returns an error requesting changes after deleted', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 2, + 3, + error => { + expect(error.message).to.equal( + "pathname 'main.tex' not found in range" + ) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary which includes the delete', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: [], + project_ops: [ + { + remove: { + pathname: 'main.tex', + }, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + { + pathnames: ['other.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 2, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe("with text operations applied to files that don't exist", function (done) { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'not_here.tex', + textOperation: [3, 'Hello world'], + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id, undefined], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToSummarizedUpdates', function () { + it('should return an empty diff instead of an error', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'not_here.tex', + 0, + 1, + (error, result) => { + expect(error).to.equal(null) + expect(result.updates.length).to.equal(0) + done() + } + ) + }) + }) + }) + + describe("with rename operations applied to files that don't exist", function (done) { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'not_here.tex', newPathname: 'blah.tex' }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id, undefined], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToSummarizedUpdates', function () { + it('should return an empty diff instead of an error', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'not_here.tex', + 0, + 1, + (error, result) => { + expect(error).to.equal(null) + expect(result.updates.length).to.equal(0) + done() + } + ) + }) + }) + }) + + describe("with remove operations applied to files that don't exist", function (done) { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [{ pathname: 'not_here.tex', newPathname: '' }], + timestamp: this.date.toISOString(), + authors: [this.author1.id, undefined], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToSummarizedUpdates', function () { + it('should return an empty diff instead of an error', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'not_here.tex', + 0, + 1, + (error, result) => { + expect(error).to.equal(null) + expect(result.updates.length).to.equal(0) + done() + } + ) + }) + }) + }) + + describe('with multiple operations in one change', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'other.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'old.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'deleted.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: ['Hello test, ', -6] }, + { pathname: 'main.tex', textOperation: [6, 'foo '] }, + { pathname: 'other.tex', textOperation: [6, 'foo '] }, + { pathname: 'old.tex', newPathname: 'new.tex' }, + { pathname: 'deleted.tex', newPathname: '' }, + { + pathname: 'created.tex', + file: { hash: this.fileHash, stringLength: 10 }, + }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + { + operations: [{ pathname: 'main.tex', textOperation: [6, -4] }], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1], + } + }) + + describe('convertToDiffUpdates', function () { + it('should can return multiple ops from the same version', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 2, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal(this.fileContents) + expect(updates).to.deep.equal([ + { + op: [ + { i: 'Hello test, ', p: 0 }, + { d: 'Hello ', p: 12 }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + op: [{ i: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + op: [{ d: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + ]) + done() + } + ) + }) + + it('should return the correct initial text if there are previous changes', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 1, + 2, + (error, param) => { + if (param == null) { + param = {} + } + const { initialContent, updates } = param + expect(error).to.be.null + expect(initialContent).to.equal( + 'Hello foo test, world, this is a test' + ) + expect(updates).to.deep.equal([ + { + op: [{ d: 'foo ', p: 6 }], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return a summary of containing multiple changes', function (done) { + const assertion = (error, updates) => { + expect(error).to.be.null + expect(updates).to.deep.equal([ + { + pathnames: ['main.tex', 'other.tex'], + project_ops: [ + { + rename: { + pathname: 'old.tex', + newPathname: 'new.tex', + }, + }, + { + remove: { + pathname: 'deleted.tex', + }, + }, + { + add: { + pathname: 'created.tex', + }, + }, + ], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.author1.id], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 1, + }, + ]) + done() + } + + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) + + describe('with a binary file', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + 'binary.tex': { + hash: this.fileHash, + byteLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: ['Hello test, ', -6] }, + ], + timestamp: this.date.toISOString(), + authors: [this.author1.id], + }, + ], + }, + }, + authors: [this.author1.id], + } + }) + + describe('convertToDiffUpdates', function () { + it('should convert them to a binary diff', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'binary.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff).to.deep.equal({ binary: true }) + done() + } + ) + }) + }) + }) + + describe('with v2 author ids', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: { + 'main.tex': { + hash: this.fileHash, + stringLength: 42, + }, + }, + }, + changes: [ + { + operations: [ + { pathname: 'main.tex', textOperation: ['Hello test, ', -6] }, + ], + timestamp: this.date.toISOString(), + v2Authors: [(this.v2AuthorId = '123456789')], + }, + ], + }, + }, + } + }) + + describe('convertToDiffUpdates', function () { + it('should return the v2 author id in the users array', function (done) { + this.ChunkTranslator.convertToDiffUpdates( + this.projectId, + this.chunk, + 'main.tex', + 0, + 1, + (error, diff) => { + expect(error).to.be.null + expect(diff.updates).to.deep.equal([ + { + op: [ + { i: 'Hello test, ', p: 0 }, + { d: 'Hello ', p: 12 }, + ], + meta: { + users: [this.v2AuthorId], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + ) + }) + }) + + describe('convertToSummarizedUpdates', function () { + it('should return the v2 author id in the users array', function (done) { + const assertion = (error, updateSet) => { + expect(error).to.be.null + expect(updateSet).to.deep.equal([ + { + pathnames: ['main.tex'], + project_ops: [], + meta: { + users: [this.v2AuthorId], + start_ts: this.date.getTime(), + end_ts: this.date.getTime(), + }, + v: 0, + }, + ]) + done() + } + this.ChunkTranslator.convertToSummarizedUpdates(this.chunk, assertion) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js b/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js new file mode 100644 index 0000000000..037207f952 --- /dev/null +++ b/services/project-history/test/unit/js/DiffGenerator/DiffGeneratorTests.js @@ -0,0 +1,390 @@ +/* eslint-disable + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/DiffGenerator.js' + +describe('DiffGenerator', function () { + beforeEach(async function () { + this.DiffGenerator = await esmock(MODULE_PATH, {}) + this.ts = Date.now() + this.user_id = 'mock-user-id' + this.user_id_2 = 'mock-user-id-2' + return (this.meta = { + start_ts: this.ts, + end_ts: this.ts, + user_id: this.user_id, + }) + }) + + describe('buildDiff', function () { + beforeEach(function () { + this.diff = [{ u: 'mock-diff' }] + this.content = 'Hello world' + this.updates = [ + { i: 'mock-update-1' }, + { i: 'mock-update-2' }, + { i: 'mock-update-3' }, + ] + this.DiffGenerator._mocks.applyUpdateToDiff = sinon + .stub() + .returns(this.diff) + this.DiffGenerator._mocks.compressDiff = sinon.stub().returns(this.diff) + return (this.result = this.DiffGenerator.buildDiff( + this.content, + this.updates + )) + }) + + it('should return the diff', function () { + return this.result.should.deep.equal(this.diff) + }) + + it('should build the content into an initial diff', function () { + return this.DiffGenerator._mocks.applyUpdateToDiff + .calledWith( + [ + { + u: this.content, + }, + ], + this.updates[0] + ) + .should.equal(true) + }) + + it('should apply each update', function () { + return Array.from(this.updates).map(update => + this.DiffGenerator._mocks.applyUpdateToDiff + .calledWith(sinon.match.any, update) + .should.equal(true) + ) + }) + + return it('should compress the diff', function () { + return this.DiffGenerator._mocks.compressDiff + .calledWith(this.diff) + .should.equal(true) + }) + }) + + describe('compressDiff', function () { + describe('with adjacent inserts with the same user_id', function () { + return it('should create one update with combined meta data and min/max timestamps', function () { + const diff = this.DiffGenerator.compressDiff([ + { + i: 'foo', + meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + }, + { + i: 'bar', + meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }, + }, + ]) + return expect(diff).to.deep.equal([ + { + i: 'foobar', + meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }, + }, + ]) + }) + }) + + describe('with adjacent inserts with different user_ids', function () { + return it('should leave the inserts unchanged', function () { + const input = [ + { + i: 'foo', + meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + }, + { + i: 'bar', + meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }, + }, + ] + const output = this.DiffGenerator.compressDiff(input) + return expect(output).to.deep.equal(input) + }) + }) + + describe('with adjacent deletes with the same user_id', function () { + return it('should create one update with combined meta data and min/max timestamps', function () { + const diff = this.DiffGenerator.compressDiff([ + { + d: 'foo', + meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + }, + { + d: 'bar', + meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }, + }, + ]) + return expect(diff).to.deep.equal([ + { + d: 'foobar', + meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }, + }, + ]) + }) + }) + + return describe('with adjacent deletes with different user_ids', function () { + return it('should leave the deletes unchanged', function () { + const input = [ + { + d: 'foo', + meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }, + }, + { + d: 'bar', + meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }, + }, + ] + const output = this.DiffGenerator.compressDiff(input) + return expect(output).to.deep.equal(input) + }) + }) + }) + + return describe('applyUpdateToDiff', function () { + describe('an insert', function () { + it('should insert into the middle of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], { + op: [{ p: 3, i: 'baz' }], + meta: this.meta, + }) + return expect(diff).to.deep.equal([ + { u: 'foo' }, + { i: 'baz', meta: this.meta }, + { u: 'bar' }, + ]) + }) + + it('should insert into the start of (u)changed text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], { + op: [{ p: 0, i: 'baz' }], + meta: this.meta, + }) + return expect(diff).to.deep.equal([ + { i: 'baz', meta: this.meta }, + { u: 'foobar' }, + ]) + }) + + it('should insert into the end of (u)changed text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], { + op: [{ p: 6, i: 'baz' }], + meta: this.meta, + }) + return expect(diff).to.deep.equal([ + { u: 'foobar' }, + { i: 'baz', meta: this.meta }, + ]) + }) + + it('should insert into the middle of (i)inserted text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ i: 'foobar', meta: this.meta }], + { op: [{ p: 3, i: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { i: 'foo', meta: this.meta }, + { i: 'baz', meta: this.meta }, + { i: 'bar', meta: this.meta }, + ]) + }) + + return it('should not count deletes in the running length total', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ d: 'deleted', meta: this.meta }, { u: 'foobar' }], + { op: [{ p: 3, i: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { d: 'deleted', meta: this.meta }, + { u: 'foo' }, + { i: 'baz', meta: this.meta }, + { u: 'bar' }, + ]) + }) + }) + + return describe('a delete', function () { + describe('deleting unchanged text', function () { + it('should delete from the middle of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foobazbar' }], + { op: [{ p: 3, d: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'foo' }, + { d: 'baz', meta: this.meta }, + { u: 'bar' }, + ]) + }) + + it('should delete from the start of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foobazbar' }], + { op: [{ p: 0, d: 'foo' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { d: 'foo', meta: this.meta }, + { u: 'bazbar' }, + ]) + }) + + it('should delete from the end of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foobazbar' }], + { op: [{ p: 6, d: 'bar' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'foobaz' }, + { d: 'bar', meta: this.meta }, + ]) + }) + + return it('should delete across multiple (u)changed text parts', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foo' }, { u: 'baz' }, { u: 'bar' }], + { op: [{ p: 2, d: 'obazb' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'fo' }, + { d: 'o', meta: this.meta }, + { d: 'baz', meta: this.meta }, + { d: 'b', meta: this.meta }, + { u: 'ar' }, + ]) + }) + }) + + describe('deleting inserts', function () { + it('should delete from the middle of (i)nserted text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ i: 'foobazbar', meta: this.meta }], + { op: [{ p: 3, d: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { i: 'foo', meta: this.meta }, + { i: 'bar', meta: this.meta }, + ]) + }) + + it('should delete from the start of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ i: 'foobazbar', meta: this.meta }], + { op: [{ p: 0, d: 'foo' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }]) + }) + + it('should delete from the end of (u)nchanged text', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ i: 'foobazbar', meta: this.meta }], + { op: [{ p: 6, d: 'bar' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }]) + }) + + return it('should delete across multiple (u)changed and (i)nserted text parts', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foo' }, { i: 'baz', meta: this.meta }, { u: 'bar' }], + { op: [{ p: 2, d: 'obazb' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'fo' }, + { d: 'o', meta: this.meta }, + { d: 'b', meta: this.meta }, + { u: 'ar' }, + ]) + }) + }) + + describe('deleting over existing deletes', function () { + return it('should delete across multiple (u)changed and (d)deleted text parts', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foo' }, { d: 'baz', meta: this.meta }, { u: 'bar' }], + { op: [{ p: 2, d: 'ob' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'fo' }, + { d: 'o', meta: this.meta }, + { d: 'baz', meta: this.meta }, + { d: 'b', meta: this.meta }, + { u: 'ar' }, + ]) + }) + }) + + describe("deleting when the text doesn't match", function () { + it('should throw an error when deleting from the middle of (u)nchanged text', function () { + return expect(() => + this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { + op: [{ p: 3, d: 'xxx' }], + meta: this.meta, + }) + ).to.throw(this.DiffGenerator.ConsistencyError) + }) + + it('should throw an error when deleting from the start of (u)nchanged text', function () { + return expect(() => + this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { + op: [{ p: 0, d: 'xxx' }], + meta: this.meta, + }) + ).to.throw(this.DiffGenerator.ConsistencyError) + }) + + return it('should throw an error when deleting from the end of (u)nchanged text', function () { + return expect(() => + this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], { + op: [{ p: 6, d: 'xxx' }], + meta: this.meta, + }) + ).to.throw(this.DiffGenerator.ConsistencyError) + }) + }) + + describe('when the last update in the existing diff is a delete', function () { + return it('should insert the new update before the delete', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ u: 'foo' }, { d: 'bar', meta: this.meta }], + { op: [{ p: 3, i: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { u: 'foo' }, + { i: 'baz', meta: this.meta }, + { d: 'bar', meta: this.meta }, + ]) + }) + }) + + return describe('when the only update in the existing diff is a delete', function () { + return it('should insert the new update after the delete', function () { + const diff = this.DiffGenerator.applyUpdateToDiff( + [{ d: 'bar', meta: this.meta }], + { op: [{ p: 0, i: 'baz' }], meta: this.meta } + ) + return expect(diff).to.deep.equal([ + { d: 'bar', meta: this.meta }, + { i: 'baz', meta: this.meta }, + ]) + }) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/DiffManager/DiffManagerTests.js b/services/project-history/test/unit/js/DiffManager/DiffManagerTests.js new file mode 100644 index 0000000000..ba2c15543d --- /dev/null +++ b/services/project-history/test/unit/js/DiffManager/DiffManagerTests.js @@ -0,0 +1,523 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/DiffManager.js' + +describe('DiffManager', function () { + beforeEach(async function () { + this.DocumentUpdaterManager = {} + this.DiffGenerator = { + buildDiff: sinon.stub(), + } + this.UpdatesProcessor = { + processUpdatesForProject: sinon.stub(), + } + this.HistoryStoreManager = { + getChunkAtVersion: sinon.stub(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.ChunkTranslator = { + convertToDiffUpdates: sinon.stub(), + } + this.FileTreeDiffGenerator = {} + this.DiffManager = await esmock(MODULE_PATH, { + '../../../../app/js/DocumentUpdaterManager.js': + this.DocumentUpdaterManager, + '../../../../app/js/DiffGenerator.js': this.DiffGenerator, + '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/ChunkTranslator.js': this.ChunkTranslator, + '../../../../app/js/FileTreeDiffGenerator.js': this.FileTreeDiffGenerator, + }) + this.projectId = 'mock-project-id' + this.callback = sinon.stub() + }) + + describe('getDiff', function () { + beforeEach(function () { + this.pathname = 'main.tex' + this.fromVersion = 4 + this.toVersion = 8 + this.initialContent = 'foo bar baz' + this.updates = ['mock-updates'] + this.diff = { mock: 'dif' } + this.UpdatesProcessor.processUpdatesForProject + .withArgs(this.projectId) + .yields() + this.DiffGenerator.buildDiff + .withArgs(this.initialContent, this.updates) + .returns(this.diff) + }) + + describe('with a text file', function () { + beforeEach(function () { + this.DiffManager._mocks._getProjectUpdatesBetweenVersions = sinon.stub() + this.DiffManager._mocks._getProjectUpdatesBetweenVersions + .withArgs( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion + ) + .yields(null, { + initialContent: this.initialContent, + updates: this.updates, + }) + this.DiffManager.getDiff( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion, + this.callback + ) + }) + + it('should make sure all pending updates have been process', function () { + this.UpdatesProcessor.processUpdatesForProject + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the updates from the history backend', function () { + this.DiffManager._mocks._getProjectUpdatesBetweenVersions + .calledWith( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion + ) + .should.equal(true) + }) + + it('should convert the updates to a diff', function () { + this.DiffGenerator.buildDiff + .calledWith(this.initialContent, this.updates) + .should.equal(true) + }) + + it('should return the diff', function () { + this.callback.calledWith(null, this.diff).should.equal(true) + }) + }) + + describe('with a binary file', function () { + beforeEach(function () { + this.DiffManager._mocks._getProjectUpdatesBetweenVersions = sinon.stub() + this.DiffManager._mocks._getProjectUpdatesBetweenVersions + .withArgs( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion + ) + .yields(null, { binary: true }) + this.DiffManager.getDiff( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion, + this.callback + ) + }) + + it('should make sure all pending updates have been process', function () { + this.UpdatesProcessor.processUpdatesForProject + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the updates from the history backend', function () { + this.DiffManager._mocks._getProjectUpdatesBetweenVersions + .calledWith( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion + ) + .should.equal(true) + }) + + it('should not try convert any updates to a diff', function () { + this.DiffGenerator.buildDiff.called.should.equal(false) + }) + + it('should return the binary diff', function () { + this.callback.calledWith(null, { binary: true }).should.equal(true) + }) + }) + }) + + describe('_getProjectUpdatesBetweenVersions', function () { + beforeEach(function () { + this.pathname = 'main.tex' + this.fromVersion = 4 + this.toVersion = 8 + this.chunks = ['mock-chunk-1', 'mock-chunk-2'] + this.concatted_chunk = 'mock-chunk' + this.DiffManager._mocks._concatChunks = sinon.stub() + this.DiffManager._mocks._concatChunks + .withArgs(this.chunks) + .returns(this.concatted_chunk) + this.updates = ['mock-updates'] + this.initialContent = 'foo bar baz' + this.ChunkTranslator.convertToDiffUpdates + .withArgs( + this.projectId, + this.concatted_chunk, + this.pathname, + this.fromVersion, + this.toVersion + ) + .yields(null, { + initialContent: this.initialContent, + updates: this.updates, + }) + }) + + describe('for the normal case', function () { + beforeEach(function () { + this.DiffManager._mocks._getChunks = sinon.stub() + this.DiffManager._mocks._getChunks + .withArgs(this.projectId, this.fromVersion, this.toVersion) + .yields(null, this.chunks) + this.DiffManager._getProjectUpdatesBetweenVersions( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion, + this.callback + ) + }) + + it('should get the relevant chunks', function () { + this.DiffManager._mocks._getChunks + .calledWith(this.projectId, this.fromVersion, this.toVersion) + .should.equal(true) + }) + + it('should get the concat the chunks', function () { + this.DiffManager._mocks._concatChunks + .calledWith(this.chunks) + .should.equal(true) + }) + + it('should convert the chunks to an initial version and updates', function () { + this.ChunkTranslator.convertToDiffUpdates + .calledWith( + this.projectId, + this.concatted_chunk, + this.pathname, + this.fromVersion, + this.toVersion + ) + .should.equal(true) + }) + + it('should return the initialContent and updates', function () { + this.callback + .calledWith(null, { + initialContent: this.initialContent, + updates: this.updates, + }) + .should.equal(true) + }) + }) + + describe('for the error case', function () { + beforeEach(function () { + this.DiffManager._mocks._getChunks = sinon.stub() + this.DiffManager._mocks._getChunks + .withArgs(this.projectId, this.fromVersion, this.toVersion) + .yields(new Error('failed to load chunk')) + this.DiffManager._getProjectUpdatesBetweenVersions( + this.projectId, + this.pathname, + this.fromVersion, + this.toVersion, + this.callback + ) + }) + + it('should call the callback with an error', function () { + this.callback + .calledWith(sinon.match.instanceOf(Error)) + .should.equal(true) + }) + }) + }) + + describe('_getChunks', function () { + beforeEach(function () { + this.historyId = 'mock-overleaf-id' + this.WebApiManager.getHistoryId.yields(null, this.historyId) + }) + + describe('where only one chunk is needed', function () { + beforeEach(function (done) { + this.fromVersion = 4 + this.toVersion = 8 + this.chunk = { + chunk: { + startVersion: 2, + }, // before fromVersion + } + this.HistoryStoreManager.getChunkAtVersion + .withArgs(this.projectId, this.historyId, this.toVersion) + .yields(null, this.chunk) + this.DiffManager._getChunks( + this.projectId, + this.fromVersion, + this.toVersion, + (error, chunks) => { + this.error = error + this.chunks = chunks + done() + } + ) + }) + + it("should the project's overleaf id", function () { + this.WebApiManager.getHistoryId + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should request the first chunk', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, this.toVersion) + .should.equal(true) + }) + + it('should return an array of chunks', function () { + expect(this.chunks).to.deep.equal([this.chunk]) + }) + }) + + describe('where multiple chunks are needed', function () { + beforeEach(function (done) { + this.fromVersion = 4 + this.toVersion = 8 + this.chunk1 = { + chunk: { + startVersion: 6, + }, + } + this.chunk2 = { + chunk: { + startVersion: 2, + }, + } + this.HistoryStoreManager.getChunkAtVersion + .withArgs(this.projectId, this.historyId, this.toVersion) + .yields(null, this.chunk1) + this.HistoryStoreManager.getChunkAtVersion + .withArgs( + this.projectId, + this.historyId, + this.chunk1.chunk.startVersion + ) + .yields(null, this.chunk2) + this.DiffManager._mocks._getChunks( + this.projectId, + this.fromVersion, + this.toVersion, + (error, chunks) => { + this.error = error + this.chunks = chunks + done() + } + ) + }) + + it('should request the first chunk', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, this.toVersion) + .should.equal(true) + }) + + it('should request the second chunk, from where the first one started', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith( + this.projectId, + this.historyId, + this.chunk1.chunk.startVersion + ) + .should.equal(true) + }) + + it('should return an array of chunks', function () { + expect(this.chunks).to.deep.equal([this.chunk1, this.chunk2]) + }) + }) + + describe('where more than MAX_CHUNKS are requested', function () { + beforeEach(function (done) { + this.fromVersion = 0 + this.toVersion = 8 + this.chunk1 = { + chunk: { + startVersion: 6, + }, + } + this.chunk2 = { + chunk: { + startVersion: 4, + }, + } + this.chunk3 = { + chunk: { + startVersion: 2, + }, + } + this.DiffManager.setMaxChunkRequests(2) + this.HistoryStoreManager.getChunkAtVersion + .withArgs(this.projectId, this.historyId, this.toVersion) + .yields(null, this.chunk1) + this.HistoryStoreManager.getChunkAtVersion + .withArgs( + this.projectId, + this.historyId, + this.chunk1.chunk.startVersion + ) + .yields(null, this.chunk2) + this.DiffManager._mocks._getChunks( + this.projectId, + this.fromVersion, + this.toVersion, + (error, chunks) => { + this.error = error + this.chunks = chunks + done() + } + ) + }) + + it('should request the first chunk', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, this.toVersion) + .should.equal(true) + }) + + it('should request the second chunk, from where the first one started', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith( + this.projectId, + this.historyId, + this.chunk1.chunk.startVersion + ) + .should.equal(true) + }) + + it('should not request the third chunk', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith( + this.projectId, + this.historyId, + this.chunk2.chunk.startVersion + ) + .should.equal(false) + }) + + it('should return an error', function () { + expect(this.error).to.exist + expect(this.error.message).to.equal('Diff spans too many chunks') + expect(this.error.name).to.equal('BadRequestError') + }) + }) + + describe('where fromVersion == toVersion', function () { + beforeEach(function (done) { + this.fromVersion = 4 + this.toVersion = 4 + this.chunk = { + chunk: { + startVersion: 2, + }, // before fromVersion + } + this.HistoryStoreManager.getChunkAtVersion + .withArgs(this.projectId, this.historyId, this.toVersion) + .yields(null, this.chunk) + this.DiffManager._mocks._getChunks( + this.projectId, + this.fromVersion, + this.toVersion, + (error, chunks) => { + this.error = error + this.chunks = chunks + done() + } + ) + }) + + it('should still request the first chunk (because we need the file contents)', function () { + this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, this.toVersion) + .should.equal(true) + }) + + it('should return an array of chunks', function () { + expect(this.chunks).to.deep.equal([this.chunk]) + }) + }) + }) + + describe('_concatChunks', function () { + it('should concat the chunks in reverse order', function () { + const result = this.DiffManager._mocks._concatChunks([ + { + chunk: { + history: { + snapshot: { + files: { + mock: 'files-updated-2', + }, + }, + changes: [7, 8, 9], + }, + }, + }, + { + chunk: { + history: { + snapshot: { + files: { + mock: 'files-updated', + }, + }, + changes: [4, 5, 6], + }, + }, + }, + { + chunk: { + history: { + snapshot: { + files: { + mock: 'files-original', + }, + }, + changes: [1, 2, 3], + }, + }, + }, + ]) + + expect(result).to.deep.equal({ + chunk: { + history: { + snapshot: { + files: { + mock: 'files-original', + }, + }, + changes: [1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + }, + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/DocumentUpdaterManager/DocumentUpdaterManagerTests.js b/services/project-history/test/unit/js/DocumentUpdaterManager/DocumentUpdaterManagerTests.js new file mode 100644 index 0000000000..a745eb4c06 --- /dev/null +++ b/services/project-history/test/unit/js/DocumentUpdaterManager/DocumentUpdaterManagerTests.js @@ -0,0 +1,184 @@ +/* eslint-disable + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/DocumentUpdaterManager.js' + +describe('DocumentUpdaterManager', function () { + beforeEach(async function () { + this.settings = { + apis: { documentupdater: { url: 'http://example.com' } }, + } + this.request = { + get: sinon.stub(), + post: sinon.stub(), + } + this.DocumentUpdaterManager = await esmock(MODULE_PATH, { + request: this.request, + '@overleaf/settings': this.settings, + }) + this.callback = sinon.stub() + this.lines = ['one', 'two', 'three'] + return (this.version = 42) + }) + + describe('getDocument', function () { + describe('successfully', function () { + beforeEach(function () { + this.body = JSON.stringify({ + lines: this.lines, + version: this.version, + ops: [], + }) + this.request.get.yields(null, { statusCode: 200 }, this.body) + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.callback + ) + }) + + it('should get the document from the document updater', function () { + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}` + return this.request.get.calledWith(url).should.equal(true) + }) + + return it('should call the callback with the content and version', function () { + return this.callback + .calledWith(null, this.lines.join('\n'), this.version) + .should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function () { + this.error = new Error('something went wrong') + this.request.get.yields(this.error, null, null) + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.callback + ) + }) + + return it('should return an error to the callback', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + return describe('when the document updater returns a failure error code', function () { + beforeEach(function () { + this.request.get.yields(null, { statusCode: 500 }, '') + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.callback + ) + }) + + return it('should return the callback with an error', function () { + return this.callback + .calledWith( + sinon.match.has( + 'message', + 'doc updater returned a non-success status code: 500' + ) + ) + .should.equal(true) + }) + }) + }) + + return describe('setDocument', function () { + beforeEach(function () { + this.content = 'mock content' + return (this.user_id = 'user-id-123') + }) + + describe('successfully', function () { + beforeEach(function () { + this.request.post.yields(null, { statusCode: 200 }) + return this.DocumentUpdaterManager.setDocument( + this.project_id, + this.doc_id, + this.content, + this.user_id, + this.callback + ) + }) + + it('should set the document in the document updater', function () { + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}` + return this.request.post + .calledWith({ + url, + json: { + lines: this.content.split('\n'), + source: 'restore', + user_id: this.user_id, + undoing: true, + }, + }) + .should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function () { + this.error = new Error('something went wrong') + this.request.post.yields(this.error, null, null) + return this.DocumentUpdaterManager.setDocument( + this.project_id, + this.doc_id, + this.content, + this.user_id, + this.callback + ) + }) + + return it('should return an error to the callback', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + return describe('when the document updater returns a failure error code', function () { + beforeEach(function () { + this.request.post.yields(null, { statusCode: 500 }, '') + return this.DocumentUpdaterManager.setDocument( + this.project_id, + this.doc_id, + this.content, + this.user_id, + this.callback + ) + }) + + return it('should return the callback with an error', function () { + return this.callback + .calledWith( + sinon.match.has( + 'message', + 'doc updater returned a non-success status code: 500' + ) + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/ErrorRecorder/ErrorRecorderTest.js b/services/project-history/test/unit/js/ErrorRecorder/ErrorRecorderTest.js new file mode 100644 index 0000000000..f42cff8c01 --- /dev/null +++ b/services/project-history/test/unit/js/ErrorRecorder/ErrorRecorderTest.js @@ -0,0 +1,123 @@ +/* eslint-disable + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { strict as esmock } from 'esmock' +import tk from 'timekeeper' + +const MODULE_PATH = '../../../../app/js/ErrorRecorder.js' + +describe('ErrorRecorder', function () { + beforeEach(async function () { + this.now = new Date() + tk.freeze(this.now) + this.callback = sinon.stub() + this.db = { + projectHistoryFailures: { + deleteOne: sinon.stub().yields(), + updateOne: sinon.stub().yields(), + }, + } + this.mongodb = { db: this.db } + this.metrics = { gauge: sinon.stub() } + this.ErrorRecorder = await esmock(MODULE_PATH, { + '../../../../app/js/mongodb.js': this.mongodb, + '@overleaf/metrics': this.metrics, + }) + + this.project_id = 'project-id-123' + return (this.queueSize = 445) + }) + + afterEach(function () { + return tk.reset() + }) + + return describe('record', function () { + describe('with an error', function () { + beforeEach(function () { + this.error = new Error('something bad') + return this.ErrorRecorder.record( + this.project_id, + this.queueSize, + this.error, + this.callback + ) + }) + + it('should record the error to mongo', function () { + return this.db.projectHistoryFailures.updateOne + .calledWithMatch( + { + project_id: this.project_id, + }, + { + $set: { + queueSize: this.queueSize, + error: this.error.toString(), + stack: this.error.stack, + ts: this.now, + }, + $inc: { + attempts: 1, + }, + $push: { + history: { + $each: [ + { + queueSize: this.queueSize, + error: this.error.toString(), + stack: this.error.stack, + ts: this.now, + }, + ], + $position: 0, + $slice: 10, + }, + }, + }, + { + upsert: true, + } + ) + .should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback + .calledWith(this.error, this.queueSize) + .should.equal(true) + }) + }) + + return describe('without an error', function () { + beforeEach(function () { + return this.ErrorRecorder.record( + this.project_id, + this.queueSize, + this.error, + this.callback + ) + }) + + it('should remove any error from mongo', function () { + return this.db.projectHistoryFailures.deleteOne + .calledWithMatch({ project_id: this.project_id }) + .should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback.calledWith(null, this.queueSize).should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/HistoryStoreManager/HistoryStoreManagerTests.js b/services/project-history/test/unit/js/HistoryStoreManager/HistoryStoreManagerTests.js new file mode 100644 index 0000000000..416df5a234 --- /dev/null +++ b/services/project-history/test/unit/js/HistoryStoreManager/HistoryStoreManagerTests.js @@ -0,0 +1,554 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' +import EventEmitter from 'events' +import * as Errors from '../../../../app/js/Errors.js' + +const MODULE_PATH = '../../../../app/js/HistoryStoreManager.js' + +describe('HistoryStoreManager', function () { + beforeEach(async function () { + this.projectId = '123456789012345678901234' + this.historyId = 'mock-ol-project-id' + this.settings = { + overleaf: { + history: { + host: 'http://example.com', + user: 'sharelatex', + pass: 'password', + }, + }, + apis: { + filestore: { + url: 'http://filestore.sharelatex.production', + }, + }, + } + this.latestChunkRequestArgs = sinon.match({ + method: 'GET', + url: `${this.settings.overleaf.history.host}/projects/${this.historyId}/latest/history`, + json: true, + auth: { + user: this.settings.overleaf.history.user, + pass: this.settings.overleaf.history.pass, + sendImmediately: true, + }, + }) + + this.callback = sinon.stub() + this.LocalFileWriter = { + bufferOnDisk: sinon.stub(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.WebApiManager.getHistoryId + .withArgs(this.projectId) + .yields(null, this.historyId) + this.request = sinon.stub() + this.request.get = sinon.stub() + + this.HistoryStoreManager = await esmock(MODULE_PATH, { + request: this.request, + '@overleaf/settings': this.settings, + '../../../../app/js/LocalFileWriter.js': this.LocalFileWriter, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/Errors.js': Errors, + }) + }) + + describe('getMostRecentChunk', function () { + describe('successfully', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 0, + history: { + snapshot: { + files: {}, + }, + changes: [], + }, + }, + } + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.chunk) + this.HistoryStoreManager.getMostRecentChunk( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should call the callback with the chunk', function () { + expect(this.callback).to.have.been.calledWith(null, this.chunk) + }) + }) + }) + + describe('getMostRecentVersion', function () { + describe('successfully', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 5, + history: { + snapshot: { + files: {}, + }, + changes: [ + { v2Authors: ['5678'], timestamp: '2017-10-17T10:44:40.227Z' }, + { v2Authors: ['1234'], timestamp: '2017-10-16T10:44:40.227Z' }, + ], + }, + }, + } + + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.chunk) + this.HistoryStoreManager.getMostRecentVersion( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should call the callback with the latest version information', function () { + expect(this.callback).to.have.been.calledWith( + null, + 7, + { project: undefined, docs: {} }, + { v2Authors: ['5678'], timestamp: '2017-10-17T10:44:40.227Z' } + ) + }) + }) + + describe('out of order doc ops', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 5, + history: { + snapshot: { + v2DocVersions: { + mock_doc_id: { + pathname: '/main.tex', + v: 2, + }, + }, + }, + changes: [ + { + operations: [], + v2DocVersions: { + mock_doc_id: { + pathname: '/main.tex', + v: 1, + }, + }, + }, + ], + }, + }, + } + + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.chunk) + this.HistoryStoreManager.getMostRecentVersion( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should return an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match + .instanceOf(Errors.OpsOutOfOrderError) + .and(sinon.match.has('message', 'doc version out of order')) + ) + }) + + it('should call the callback with the latest version information', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match.instanceOf(Errors.OpsOutOfOrderError), + 6, + { + project: undefined, + docs: { mock_doc_id: { pathname: '/main.tex', v: 2 } }, + }, + this.chunk.chunk.history.changes[0] + ) + }) + }) + + describe('out of order project structure versions', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 5, + history: { + snapshot: { + projectVersion: 2, + }, + changes: [ + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + projectVersion: 1, + }, + ], + }, + }, + } + + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.chunk) + this.HistoryStoreManager.getMostRecentVersion( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should return an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match + .instanceOf(Errors.OpsOutOfOrderError) + .and( + sinon.match.has( + 'message', + 'project structure version out of order' + ) + ) + ) + }) + + it('should call the callback with the latest version information', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match.instanceOf(Errors.OpsOutOfOrderError), + 6, + { project: 2, docs: {} }, + this.chunk.chunk.history.changes[0] + ) + }) + }) + + describe('out of order project structure and doc versions', function () { + beforeEach(function () { + this.chunk = { + chunk: { + startVersion: 5, + history: { + snapshot: { + projectVersion: 1, + }, + changes: [ + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + projectVersion: 1, + }, + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + projectVersion: 2, + }, + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + projectVersion: 3, + }, + { + operations: [{ pathname: 'main.tex', newPathname: '' }], + projectVersion: 1, + }, + { + operations: [], + v2DocVersions: { + mock_doc_id: { + pathname: '/main.tex', + v: 1, + }, + }, + }, + { + operations: [], + v2DocVersions: { + mock_doc_id: { + pathname: '/main.tex', + v: 2, + }, + }, + }, + { + operations: [], + v2DocVersions: { + mock_doc_id: { + pathname: '/main.tex', + v: 1, + }, + }, + }, + ], + }, + }, + } + + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.chunk) + this.HistoryStoreManager.getMostRecentVersion( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should return an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match + .instanceOf(Errors.OpsOutOfOrderError) + .and( + sinon.match.has( + 'message', + 'project structure version out of order' + ) + ) + ) + }) + + it('should call the callback with the latest version information', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match.instanceOf(Errors.OpsOutOfOrderError), + 12, + { + project: 3, + docs: { mock_doc_id: { pathname: '/main.tex', v: 2 } }, + }, + this.chunk.chunk.history.changes[6] + ) + }) + }) + + describe('with an unexpected response', function () { + beforeEach(function () { + this.badChunk = { + chunk: { + foo: 123, // valid chunk should have startVersion property + bar: 456, + }, + } + this.request + .withArgs(this.latestChunkRequestArgs) + .yields(null, { statusCode: 200 }, this.badChunk) + this.HistoryStoreManager.getMostRecentVersion( + this.projectId, + this.historyId, + this.callback + ) + }) + + it('should return an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match + .instanceOf(Error) + .and(sinon.match.has('message', 'unexpected response')) + ) + }) + }) + }) + + describe('createBlobForUpdate', function () { + beforeEach(function () { + this.fileStream = { + pause: sinon.stub(), + resume: sinon.stub(), + on: sinon.stub(), + } + this.hash = 'random-hash' + this.fileStream.on + .withArgs('response') + .callsArgWith(1, { statusCode: 200 }) + this.LocalFileWriter.bufferOnDisk.callsArgWith(3, null, this.hash) + this.request.get.returns(this.fileStream) + }) + + describe('for a file update with any filestore location', function () { + beforeEach(function (done) { + this.file_id = '012345678901234567890123' + this.update = { + file: true, + url: `http://filestore.other.cloud.provider/project/${this.projectId}/file/${this.file_id}`, + } + this.HistoryStoreManager.createBlobForUpdate( + this.projectId, + this.historyId, + this.update, + (err, hash) => { + if (err) { + return done(err) + } + this.actualHash = hash + done() + } + ) + }) + + it('should request the file from the filestore in settings', function () { + expect(this.request.get).to.have.been.calledWithMatch({ + url: `${this.settings.apis.filestore.url}/project/${this.projectId}/file/${this.file_id}`, + }) + }) + + it('should call the callback with the blob', function () { + expect(this.actualHash).to.equal(this.hash) + }) + }) + + describe('for a file update with an invalid filestore location', function () { + beforeEach(function (done) { + this.invalid_id = '000000000000000000000000' + this.file_id = '012345678901234567890123' + this.update = { + file: true, + url: `http://filestore.other.cloud.provider/project/${this.invalid_id}/file/${this.file_id}`, + } + this.HistoryStoreManager.createBlobForUpdate( + this.projectId, + this.historyId, + this.update, + err => { + expect(err).to.exist + done() + } + ) + }) + + it('should not request the file from the filestore', function () { + expect(this.request.get).to.not.have.been.called + }) + }) + }) + + describe('getProjectBlob', function () { + describe('successfully', function () { + beforeEach(function () { + this.blobContent = 'test content' + this.blobHash = 'test hash' + + this.request.yields(null, { statusCode: 200 }, this.blobContent) + this.HistoryStoreManager.getProjectBlob( + this.historyId, + this.blobHash, + this.callback + ) + }) + + it('should get the blob from the overleaf history service', function () { + expect(this.request).to.have.been.calledWithMatch({ + method: 'GET', + url: `${this.settings.overleaf.history.host}/projects/${this.historyId}/blobs/${this.blobHash}`, + auth: { + user: this.settings.overleaf.history.user, + pass: this.settings.overleaf.history.pass, + sendImmediately: true, + }, + }) + }) + + it('should call the callback with the blob', function () { + expect(this.callback).to.have.been.calledWith(null, this.blobContent) + }) + }) + }) + + describe('getProjectBlobStream', function () { + describe('successfully', function () { + beforeEach(function (done) { + this.historyResponse = new EventEmitter() + this.blobHash = 'test hash' + + this.request.returns(this.historyResponse) + this.HistoryStoreManager.getProjectBlobStream( + this.historyId, + this.blobHash, + (err, stream) => { + if (err) { + return done(err) + } + this.stream = stream + done() + } + ) + this.historyResponse.emit('response', { statusCode: 200 }) + }) + + it('should get the blob from the overleaf history service', function () { + expect(this.request).to.have.been.calledWithMatch({ + method: 'GET', + url: `${this.settings.overleaf.history.host}/projects/${this.historyId}/blobs/${this.blobHash}`, + auth: { + user: this.settings.overleaf.history.user, + pass: this.settings.overleaf.history.pass, + sendImmediately: true, + }, + }) + }) + + it('should return a stream of the blob contents', function () { + expect(this.stream).to.equal(this.historyResponse) + }) + }) + }) + + describe('initializeProject', function () { + describe('successfully', function () { + beforeEach(function () { + this.response_body = { projectId: this.historyId } + this.request.callsArgWith( + 1, + null, + { statusCode: 200 }, + this.response_body + ) + + this.HistoryStoreManager.initializeProject( + this.historyId, + this.callback + ) + }) + + it('should send the change to the history store', function () { + expect(this.request).to.have.been.calledWithMatch({ + method: 'POST', + url: `${this.settings.overleaf.history.host}/projects`, + auth: { + user: this.settings.overleaf.history.user, + pass: this.settings.overleaf.history.pass, + sendImmediately: true, + }, + json: { projectId: this.historyId }, + }) + }) + + it('should call the callback with the new overleaf id', function () { + expect(this.callback).to.have.been.calledWith(null, this.historyId) + }) + }) + }) + + describe('deleteProject', function () { + beforeEach(function (done) { + this.request.yields(null, { statusCode: 204 }, '') + this.HistoryStoreManager.deleteProject(this.historyId, done) + }) + + it('should ask the history store to delete the project', function () { + expect(this.request).to.have.been.calledWithMatch({ + method: 'DELETE', + url: `${this.settings.overleaf.history.host}/projects/${this.historyId}`, + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/HttpController/HttpControllerTests.js b/services/project-history/test/unit/js/HttpController/HttpControllerTests.js new file mode 100644 index 0000000000..a74e342c2a --- /dev/null +++ b/services/project-history/test/unit/js/HttpController/HttpControllerTests.js @@ -0,0 +1,541 @@ +import sinon from 'sinon' +import { strict as esmock } from 'esmock' +import { ObjectId } from 'mongodb' + +const MODULE_PATH = '../../../../app/js/HttpController.js' + +describe('HttpController', function () { + beforeEach(async function () { + this.UpdatesProcessor = { + processUpdatesForProject: sinon.stub().yields(), + } + this.SummarizedUpdatesManager = { + getSummarizedProjectUpdates: sinon.stub(), + } + this.DiffManager = { + getDiff: sinon.stub(), + } + this.HistoryStoreManager = { + deleteProject: sinon.stub().yields(), + getMostRecentVersion: sinon.stub(), + getProjectBlobStream: sinon.stub(), + initializeProject: sinon.stub(), + } + this.SnapshotManager = { + getFileSnapshotStream: sinon.stub(), + getProjectSnapshot: sinon.stub(), + } + this.HealthChecker = {} + this.SyncManager = { + clearResyncState: sinon.stub().yields(), + startResync: sinon.stub().yields(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.RedisManager = { + destroyDocUpdatesQueue: sinon.stub().yields(), + clearFirstOpTimestamp: sinon.stub().yields(), + clearCachedHistoryId: sinon.stub().yields(), + } + this.ErrorRecorder = { + record: sinon.stub().yields(), + } + this.LabelsManager = { + createLabel: sinon.stub(), + deleteLabel: sinon.stub().yields(), + getLabels: sinon.stub(), + } + this.HistoryApiManager = { + shouldUseProjectHistory: sinon.stub(), + } + this.RetryManager = {} + this.FlushManager = {} + this.request = {} + this.HttpController = await esmock(MODULE_PATH, { + request: this.request, + '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor, + '../../../../app/js/SummarizedUpdatesManager.js': + this.SummarizedUpdatesManager, + '../../../../app/js/DiffManager.js': this.DiffManager, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/SnapshotManager.js': this.SnapshotManager, + '../../../../app/js/HealthChecker.js': this.HealthChecker, + '../../../../app/js/SyncManager.js': this.SyncManager, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/RedisManager.js': this.RedisManager, + '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder, + '../../../../app/js/LabelsManager.js': this.LabelsManager, + '../../../../app/js/HistoryApiManager.js': this.HistoryApiManager, + '../../../../app/js/RetryManager.js': this.RetryManager, + '../../../../app/js/FlushManager.js': this.FlushManager, + }) + this.pathname = 'doc-id-123' + this.projectId = new ObjectId().toString() + this.next = sinon.stub() + this.userId = new ObjectId().toString() + this.now = Date.now() + this.res = { + json: sinon.stub(), + send: sinon.stub(), + sendStatus: sinon.stub(), + } + }) + + describe('getProjectBlob', function () { + beforeEach(function () { + this.blobHash = 'abcd' + this.stream = { pipe: sinon.stub() } + this.HistoryStoreManager.getProjectBlobStream.yields(null, this.stream) + this.HttpController.getProjectBlob( + { params: { project_id: this.projectId, hash: this.blobHash } }, + this.res, + this.next + ) + }) + + it('should get a blob stream', function () { + this.HistoryStoreManager.getProjectBlobStream + .calledWith(this.projectId, this.blobHash) + .should.equal(true) + this.stream.pipe.calledWith(this.res).should.equal(true) + }) + }) + + describe('initializeProject', function () { + beforeEach(function () { + this.historyId = ObjectId().toString() + this.req = { body: { historyId: this.historyId } } + this.HistoryStoreManager.initializeProject.yields(null, this.historyId) + this.HttpController.initializeProject(this.req, this.res, this.next) + }) + + it('should initialize the project', function () { + this.HistoryStoreManager.initializeProject.calledWith().should.equal(true) + }) + + it('should return the new overleaf id', function () { + this.res.json + .calledWith({ project: { id: this.historyId } }) + .should.equal(true) + }) + }) + + describe('flushProject', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + }, + query: {}, + } + this.HttpController.flushProject(this.req, this.res, this.next) + }) + + it('should process the updates', function () { + this.UpdatesProcessor.processUpdatesForProject + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should return a success code', function () { + this.res.sendStatus.calledWith(204).should.equal(true) + }) + }) + + describe('getDiff', function () { + beforeEach(function () { + this.from = 42 + this.to = 45 + this.req = { + params: { + project_id: this.projectId, + }, + query: { + pathname: this.pathname, + from: this.from, + to: this.to, + }, + } + this.diff = [{ u: 'mock-diff' }] + this.DiffManager.getDiff.yields(null, this.diff) + this.HttpController.getDiff(this.req, this.res, this.next) + }) + + it('should get the diff', function () { + this.DiffManager.getDiff.should.have.been.calledWith( + this.projectId, + this.pathname, + this.from, + this.to + ) + }) + + it('should return the diff', function () { + this.res.json.calledWith({ diff: this.diff }).should.equal(true) + }) + }) + + describe('getUpdates', function () { + beforeEach(function () { + this.before = Date.now() + this.nextBeforeTimestamp = this.before - 100 + this.min_count = 10 + this.req = { + params: { + project_id: this.projectId, + }, + query: { + before: this.before, + min_count: this.min_count, + }, + } + this.updates = [{ i: 'mock-summarized-updates', p: 10 }] + this.SummarizedUpdatesManager.getSummarizedProjectUpdates.yields( + null, + this.updates, + this.nextBeforeTimestamp + ) + this.HttpController.getUpdates(this.req, this.res, this.next) + }) + + it('should get the updates', function () { + this.SummarizedUpdatesManager.getSummarizedProjectUpdates.should.have.been.calledWith( + this.projectId, + { + before: this.before, + min_count: this.min_count, + } + ) + }) + + it('should return the formatted updates', function () { + this.res.json.should.have.been.calledWith({ + updates: this.updates, + nextBeforeTimestamp: this.nextBeforeTimestamp, + }) + }) + }) + + describe('latestVersion', function () { + beforeEach(function () { + this.historyId = 1234 + this.req = { + params: { + project_id: this.projectId, + }, + } + + this.version = 99 + this.lastChange = { + v2Authors: ['1234'], + timestamp: '2016-08-16T10:44:40.227Z', + } + this.versionInfo = { + version: this.version, + v2Authors: ['1234'], + timestamp: '2016-08-16T10:44:40.227Z', + } + this.WebApiManager.getHistoryId.yields(null, this.historyId) + this.HistoryStoreManager.getMostRecentVersion.yields( + null, + this.version, + {}, + this.lastChange + ) + this.HttpController.latestVersion(this.req, this.res, this.next) + }) + + it('should process the updates', function () { + this.UpdatesProcessor.processUpdatesForProject + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the ol project id', function () { + this.WebApiManager.getHistoryId + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the latest version', function () { + this.HistoryStoreManager.getMostRecentVersion + .calledWith(this.projectId, this.historyId) + .should.equal(true) + }) + + it('should return version number', function () { + this.res.json.calledWith(this.versionInfo).should.equal(true) + }) + }) + + describe('resyncProject', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + }, + query: {}, + body: {}, + } + this.HttpController.resyncProject(this.req, this.res, this.next) + }) + + it('should resync the project', function () { + this.SyncManager.startResync.calledWith(this.projectId).should.equal(true) + }) + + it('should flush the queue', function () { + this.UpdatesProcessor.processUpdatesForProject + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should return 204', function () { + this.res.sendStatus.calledWith(204).should.equal(true) + }) + }) + + describe('getFileSnapshot', function () { + beforeEach(function () { + this.version = 42 + this.pathname = 'foo.tex' + this.req = { + params: { + project_id: this.projectId, + version: this.version, + pathname: this.pathname, + }, + } + this.res = { mock: 'res' } + this.stream = { pipe: sinon.stub() } + this.SnapshotManager.getFileSnapshotStream.yields(null, this.stream) + this.HttpController.getFileSnapshot(this.req, this.res, this.next) + }) + + it('should get the snapshot', function () { + this.SnapshotManager.getFileSnapshotStream.should.have.been.calledWith( + this.projectId, + this.version, + this.pathname + ) + }) + + it('should pipe the returned stream into the response', function () { + this.stream.pipe.calledWith(this.res).should.equal(true) + }) + }) + + describe('getProjectSnapshot', function () { + beforeEach(function () { + this.version = 42 + this.req = { + params: { + project_id: this.projectId, + version: this.version, + }, + } + this.res = { json: sinon.stub() } + this.snapshotData = { one: 1 } + this.SnapshotManager.getProjectSnapshot.yields(null, this.snapshotData) + this.HttpController.getProjectSnapshot(this.req, this.res, this.next) + }) + + it('should get the snapshot', function () { + this.SnapshotManager.getProjectSnapshot.should.have.been.calledWith( + this.projectId, + this.version + ) + }) + + it('should send json response', function () { + this.res.json.calledWith(this.snapshotData).should.equal(true) + }) + }) + + describe('getLabels', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + }, + } + this.labels = ['label-1', 'label-2'] + this.LabelsManager.getLabels.yields(null, this.labels) + }) + + describe('project history is enabled', function () { + beforeEach(function () { + this.HistoryApiManager.shouldUseProjectHistory.yields(null, true) + this.HttpController.getLabels(this.req, this.res, this.next) + }) + + it('should get the labels for a project', function () { + this.LabelsManager.getLabels + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should return the labels', function () { + this.res.json.calledWith(this.labels).should.equal(true) + }) + }) + + describe('project history is not enabled', function () { + beforeEach(function () { + this.HistoryApiManager.shouldUseProjectHistory.yields(null, false) + this.HttpController.getLabels(this.req, this.res, this.next) + }) + + it('should return 409', function () { + this.res.sendStatus.calledWith(409).should.equal(true) + }) + }) + }) + + describe('createLabel', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + user_id: this.userId, + }, + body: { + version: (this.version = 'label-1'), + comment: (this.comment = 'a comment'), + created_at: (this.created_at = Date.now().toString()), + validate_exists: true, + }, + } + this.label = { _id: new ObjectId() } + this.LabelsManager.createLabel.yields(null, this.label) + }) + + describe('project history is enabled', function () { + beforeEach(function () { + this.HistoryApiManager.shouldUseProjectHistory.yields(null, true) + this.HttpController.createLabel(this.req, this.res, this.next) + }) + + it('should create a label for a project', function () { + this.LabelsManager.createLabel.should.have.been.calledWith( + this.projectId, + this.userId, + this.version, + this.comment, + this.created_at, + true + ) + }) + + it('should return the label', function () { + this.res.json.calledWith(this.label).should.equal(true) + }) + }) + + describe('validate_exists = false is passed', function () { + beforeEach(function () { + this.req.body.validate_exists = false + this.HistoryApiManager.shouldUseProjectHistory.yields(null, true) + this.HttpController.createLabel(this.req, this.res, this.next) + }) + + it('should create a label for a project', function () { + this.LabelsManager.createLabel + .calledWith( + this.projectId, + this.userId, + this.version, + this.comment, + this.created_at, + false + ) + .should.equal(true) + }) + + it('should return the label', function () { + this.res.json.calledWith(this.label).should.equal(true) + }) + }) + + describe('project history is not enabled', function () { + beforeEach(function () { + this.HistoryApiManager.shouldUseProjectHistory.yields(null, false) + this.HttpController.createLabel(this.req, this.res, this.next) + }) + + it('should return 409', function () { + this.res.sendStatus.calledWith(409).should.equal(true) + }) + }) + }) + + describe('deleteLabel', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + user_id: this.userId, + label_id: (this.label_id = ObjectId()), + }, + } + this.HttpController.deleteLabel(this.req, this.res, this.next) + }) + + it('should create a label for a project', function () { + this.LabelsManager.deleteLabel + .calledWith(this.projectId, this.userId, this.label_id) + .should.equal(true) + }) + + it('should return 204', function () { + this.res.sendStatus.calledWith(204).should.equal(true) + }) + }) + + describe('deleteProject', function () { + beforeEach(function () { + this.req = { + params: { + project_id: this.projectId, + }, + } + this.WebApiManager.getHistoryId + .withArgs(this.projectId) + .yields(null, this.historyId) + this.HttpController.deleteProject(this.req, this.res, this.next) + }) + + it('should delete the updates queue', function () { + this.RedisManager.destroyDocUpdatesQueue.should.have.been.calledWith( + this.projectId + ) + }) + + it('should clear the first op timestamp', function () { + this.RedisManager.clearFirstOpTimestamp.should.have.been.calledWith( + this.projectId + ) + }) + + it('should clear the cached history id', function () { + this.RedisManager.clearCachedHistoryId.should.have.been.calledWith( + this.projectId + ) + }) + + it('should clear the resync state', function () { + this.SyncManager.clearResyncState.should.have.been.calledWith( + this.projectId + ) + }) + + it('should clear any failure record', function () { + this.ErrorRecorder.record.should.have.been.calledWith( + this.projectId, + 0, + null + ) + }) + }) +}) diff --git a/services/project-history/test/unit/js/LabelsManager/LabelsManagerTests.js b/services/project-history/test/unit/js/LabelsManager/LabelsManagerTests.js new file mode 100644 index 0000000000..838d5ab2a8 --- /dev/null +++ b/services/project-history/test/unit/js/LabelsManager/LabelsManagerTests.js @@ -0,0 +1,258 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { ObjectId } from 'mongodb' +import tk from 'timekeeper' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/LabelsManager.js' + +describe('LabelsManager', function () { + beforeEach(async function () { + this.now = new Date() + tk.freeze(this.now) + this.db = { + projectHistoryLabels: { + deleteOne: sinon.stub(), + find: sinon.stub(), + insertOne: sinon.stub(), + }, + } + this.mongodb = { + ObjectId, + db: this.db, + } + this.HistoryStoreManager = { + getChunkAtVersion: sinon.stub().yields(), + } + this.UpdatesProcessor = { + processUpdatesForProject: sinon.stub().yields(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.LabelsManager = await esmock(MODULE_PATH, { + '../../../../app/js/mongodb.js': this.mongodb, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + }) + + this.project_id = new ObjectId().toString() + this.historyId = 123 + this.user_id = new ObjectId().toString() + this.label_id = new ObjectId().toString() + return (this.callback = sinon.stub()) + }) + + afterEach(function () { + return tk.reset() + }) + + describe('getLabels', function () { + beforeEach(function () { + this.label = { + _id: ObjectId(), + comment: 'some comment', + version: 123, + user_id: ObjectId(), + created_at: new Date(), + } + + this.db.projectHistoryLabels.find.returns({ + toArray: sinon.stub().yields(null, [this.label]), + }) + }) + + describe('with valid project id', function () { + beforeEach(function () { + return this.LabelsManager.getLabels(this.project_id, this.callback) + }) + + it('gets the labels state from mongo', function () { + return expect( + this.db.projectHistoryLabels.find + ).to.have.been.calledWith({ project_id: ObjectId(this.project_id) }) + }) + + return it('returns formatted labels', function () { + return expect(this.callback).to.have.been.calledWith(null, [ + sinon.match({ + id: this.label._id, + comment: this.label.comment, + version: this.label.version, + user_id: this.label.user_id, + created_at: this.label.created_at, + }), + ]) + }) + }) + + return describe('with invalid project id', function () { + return it('returns an error', function (done) { + return this.LabelsManager.getLabels('invalid id', error => { + expect(error).to.exist + return done() + }) + }) + }) + }) + + describe('createLabel', function () { + beforeEach(function () { + this.version = 123 + this.comment = 'a comment' + this.WebApiManager.getHistoryId.yields(null, this.historyId) + }) + + describe('with createdAt', function () { + beforeEach(function () { + this.createdAt = new Date(1) + this.db.projectHistoryLabels.insertOne.yields(null, { + insertedId: ObjectId(this.label_id), + }) + return this.LabelsManager.createLabel( + this.project_id, + this.user_id, + this.version, + this.comment, + this.createdAt, + true, + this.callback + ) + }) + + it('flushes unprocessed updates', function () { + return expect( + this.UpdatesProcessor.processUpdatesForProject + ).to.have.been.calledWith(this.project_id) + }) + + it('finds the V1 project id', function () { + return expect(this.WebApiManager.getHistoryId).to.have.been.calledWith( + this.project_id + ) + }) + + it('checks there is a chunk for the project + version', function () { + return expect( + this.HistoryStoreManager.getChunkAtVersion + ).to.have.been.calledWith(this.project_id, this.historyId, this.version) + }) + + it('create the label in mongo', function () { + return expect( + this.db.projectHistoryLabels.insertOne + ).to.have.been.calledWith( + sinon.match({ + project_id: ObjectId(this.project_id), + comment: this.comment, + version: this.version, + user_id: ObjectId(this.user_id), + created_at: this.createdAt, + }), + sinon.match.any + ) + }) + + return it('returns the label', function () { + return expect(this.callback).to.have.been.calledWith(null, { + id: ObjectId(this.label_id), + comment: this.comment, + version: this.version, + user_id: ObjectId(this.user_id), + created_at: this.createdAt, + }) + }) + }) + + describe('without createdAt', function () { + beforeEach(function () { + this.db.projectHistoryLabels.insertOne.yields(null, { + insertedId: ObjectId(this.label_id), + }) + return this.LabelsManager.createLabel( + this.project_id, + this.user_id, + this.version, + this.comment, + undefined, + true, + this.callback + ) + }) + + return it('create the label with the current date', function () { + return expect( + this.db.projectHistoryLabels.insertOne + ).to.have.been.calledWith( + sinon.match({ + project_id: ObjectId(this.project_id), + comment: this.comment, + version: this.version, + user_id: ObjectId(this.user_id), + created_at: this.now, + }) + ) + }) + }) + + return describe('with shouldValidateExists = false', function () { + beforeEach(function () { + this.createdAt = new Date(1) + this.db.projectHistoryLabels.insertOne.yields(null, { + insertedId: ObjectId(this.label_id), + }) + return this.LabelsManager.createLabel( + this.project_id, + this.user_id, + this.version, + this.comment, + this.createdAt, + false, + this.callback + ) + }) + + return it('checks there is a chunk for the project + version', function () { + return expect(this.HistoryStoreManager.getChunkAtVersion).to.not.have + .been.called + }) + }) + }) + + return describe('deleteLabel', function () { + beforeEach(function () { + this.db.projectHistoryLabels.deleteOne.yields() + return this.LabelsManager.deleteLabel( + this.project_id, + this.user_id, + this.label_id, + this.callback + ) + }) + + return it('removes the label from the database', function () { + return expect( + this.db.projectHistoryLabels.deleteOne + ).to.have.been.calledWith( + { + _id: ObjectId(this.label_id), + project_id: ObjectId(this.project_id), + user_id: ObjectId(this.user_id), + }, + this.callback + ) + }) + }) +}) diff --git a/services/project-history/test/unit/js/LockManager/LockManagerTests.js b/services/project-history/test/unit/js/LockManager/LockManagerTests.js new file mode 100644 index 0000000000..7b186fbda0 --- /dev/null +++ b/services/project-history/test/unit/js/LockManager/LockManagerTests.js @@ -0,0 +1,423 @@ +/* eslint-disable + camelcase, + mocha/no-nested-tests, + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import async from 'async' +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/LockManager.js' + +describe('LockManager', function () { + beforeEach(async function () { + let Timer + this.Settings = { + redis: { + lock: {}, + }, + } + this.rclient = { + auth: sinon.stub(), + del: sinon.stub().yields(), + eval: sinon.stub(), + exists: sinon.stub(), + set: sinon.stub(), + } + this.RedisWrapper = { + createClient: sinon.stub().returns(this.rclient), + } + this.Metrics = { + inc: sinon.stub(), + gauge: sinon.stub(), + Timer: (Timer = (function () { + Timer = class Timer { + static initClass() { + this.prototype.done = sinon.stub() + } + } + Timer.initClass() + return Timer + })()), + } + this.logger = { + debug: sinon.stub(), + } + this.LockManager = await esmock(MODULE_PATH, { + '@overleaf/redis-wrapper': this.RedisWrapper, + '@overleaf/settings': this.Settings, + '@overleaf/metrics': this.Metrics, + '@overleaf/logger': this.logger, + }) + + this.key = 'lock-key' + this.callback = sinon.stub() + this.clock = sinon.useFakeTimers() + }) + + afterEach(function () { + this.clock.restore() + }) + + describe('checkLock', function () { + describe('when the lock is taken', function () { + beforeEach(function () { + this.rclient.exists.yields(null, '1') + return this.LockManager.checkLock(this.key, this.callback) + }) + + it('should check the lock in redis', function () { + return this.rclient.exists.calledWith(this.key).should.equal(true) + }) + + return it('should return the callback with false', function () { + return this.callback.calledWith(null, false).should.equal(true) + }) + }) + + return describe('when the lock is free', function () { + beforeEach(function () { + this.rclient.exists.yields(null, '0') + return this.LockManager.checkLock(this.key, this.callback) + }) + + return it('should return the callback with true', function () { + return this.callback.calledWith(null, true).should.equal(true) + }) + }) + }) + + describe('tryLock', function () { + describe('when the lock is taken', function () { + beforeEach(function () { + this.rclient.set.yields(null, null) + this.LockManager._mocks.randomLock = sinon + .stub() + .returns('locked-random-value') + return this.LockManager.tryLock(this.key, this.callback) + }) + + it('should check the lock in redis', function () { + return this.rclient.set.should.have.been.calledWith( + this.key, + 'locked-random-value', + 'EX', + this.LockManager.LOCK_TTL, + 'NX' + ) + }) + + return it('should return the callback with false', function () { + return this.callback.calledWith(null, false).should.equal(true) + }) + }) + + return describe('when the lock is free', function () { + beforeEach(function () { + this.rclient.set.yields(null, 'OK') + return this.LockManager.tryLock(this.key, this.callback) + }) + + return it('should return the callback with true', function () { + return this.callback.calledWith(null, true).should.equal(true) + }) + }) + }) + + describe('deleteLock', function () { + return beforeEach(function () { + beforeEach(function () { + return this.LockManager.deleteLock(this.key, this.callback) + }) + + it('should delete the lock in redis', function () { + return this.rclient.del.calledWith(key).should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + }) + }) + + describe('getLock', function () { + describe('when the lock is not taken', function () { + beforeEach(function (done) { + this.LockManager._mocks.tryLock = sinon.stub().yields(null, true) + return this.LockManager.getLock(this.key, (...args) => { + this.callback(...Array.from(args || [])) + return done() + }) + }) + + it('should try to get the lock', function () { + return this.LockManager._mocks.tryLock + .calledWith(this.key) + .should.equal(true) + }) + + it('should only need to try once', function () { + return this.LockManager._mocks.tryLock.callCount.should.equal(1) + }) + + return it('should return the callback', function () { + return this.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when the lock is initially set', function () { + beforeEach(function (done) { + this.LockManager._mocks.tryLock = sinon.stub() + this.LockManager._mocks.tryLock.onCall(0).yields(null, false) + this.LockManager._mocks.tryLock.onCall(1).yields(null, false) + this.LockManager._mocks.tryLock.onCall(2).yields(null, false) + this.LockManager._mocks.tryLock.onCall(3).yields(null, true) + + this.LockManager.getLock(this.key, (...args) => { + this.callback(...args) + return done() + }) + this.clock.runAll() + }) + + it('should call tryLock multiple times until free', function () { + this.LockManager._mocks.tryLock.callCount.should.equal(4) + }) + + return it('should return the callback', function () { + return this.callback.calledWith(null).should.equal(true) + }) + }) + + return describe('when the lock times out', function () { + beforeEach(function (done) { + const time = Date.now() + this.LockManager._mocks.tryLock = sinon.stub().yields(null, false) + this.LockManager.getLock(this.key, (...args) => { + this.callback(...args) + return done() + }) + this.clock.runAll() + }) + + return it('should return the callback with an error', function () { + return this.callback + .calledWith(sinon.match.instanceOf(Error)) + .should.equal(true) + }) + }) + }) + + return describe('runWithLock', function () { + describe('with successful run', function () { + beforeEach(function () { + this.result = 'mock-result' + this.runner = sinon.stub().callsFake((extendLock, releaseLock) => { + return releaseLock(null, this.result) + }) + this.LockManager._mocks.getLock = sinon.stub().yields() + this.LockManager._mocks.releaseLock = sinon.stub().yields() + return this.LockManager.runWithLock( + this.key, + this.runner, + this.callback + ) + }) + + it('should get the lock', function () { + return this.LockManager._mocks.getLock + .calledWith(this.key) + .should.equal(true) + }) + + it('should run the passed function', function () { + return this.runner.called.should.equal(true) + }) + + it('should release the lock', function () { + return this.LockManager._mocks.releaseLock + .calledWith(this.key) + .should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback.calledWith(null, this.result).should.equal(true) + }) + }) + + describe('when the runner function returns an error', function () { + beforeEach(function () { + this.error = new Error('oops') + this.result = 'mock-result' + this.runner = sinon.stub().callsFake((extendLock, releaseLock) => { + return releaseLock(this.error, this.result) + }) + this.LockManager._mocks.getLock = sinon.stub().yields() + this.LockManager._mocks.releaseLock = sinon.stub().yields() + return this.LockManager.runWithLock( + this.key, + this.runner, + this.callback + ) + }) + + it('should release the lock', function () { + return this.LockManager._mocks.releaseLock + .calledWith(this.key) + .should.equal(true) + }) + + return it('should call the callback with the error', function () { + return this.callback + .calledWith(this.error, this.result) + .should.equal(true) + }) + }) + + describe('extending the lock whilst running', function () { + beforeEach(function () { + this.lockValue = 'lock-value' + this.LockManager._mocks.getLock = sinon + .stub() + .yields(null, this.lockValue) + this.LockManager._mocks.extendLock = sinon.stub().callsArg(2) + this.LockManager._mocks.releaseLock = sinon.stub().callsArg(2) + }) + + it('should extend the lock if the minimum interval has been passed', function (done) { + const runner = (extendLock, releaseLock) => { + this.clock.tick(this.LockManager.MIN_LOCK_EXTENSION_INTERVAL + 1) + return extendLock(releaseLock) + } + return this.LockManager.runWithLock(this.key, runner, () => { + this.LockManager._mocks.extendLock + .calledWith(this.key, this.lockValue) + .should.equal(true) + return done() + }) + }) + + return it('should not extend the lock if the minimum interval has not been passed', function (done) { + const runner = (extendLock, releaseLock) => { + this.clock.tick(this.LockManager.MIN_LOCK_EXTENSION_INTERVAL - 1) + return extendLock(releaseLock) + } + return this.LockManager.runWithLock(this.key, runner, () => { + this.LockManager._mocks.extendLock.callCount.should.equal(0) + return done() + }) + }) + }) + + describe('exceeding the lock ttl', function () { + beforeEach(function () { + this.lockValue = 'lock-value' + this.LockManager._mocks.getLock = sinon + .stub() + .yields(null, this.lockValue) + this.LockManager._mocks.extendLock = sinon.stub().yields() + this.LockManager._mocks.releaseLock = sinon.stub().yields() + return (this.LOCK_TTL_MS = this.LockManager.LOCK_TTL * 1000) + }) + + it("doesn't log if the ttl wasn't exceeded", function (done) { + const runner = (extendLock, releaseLock) => { + this.clock.tick(this.LOCK_TTL_MS - 1) + return releaseLock() + } + return this.LockManager.runWithLock(this.key, runner, () => { + this.logger.debug.callCount.should.equal(0) + return done() + }) + }) + + it("doesn't log if the lock was extended", function (done) { + const runner = (extendLock, releaseLock) => { + this.clock.tick(this.LOCK_TTL_MS - 1) + return extendLock(() => { + this.clock.tick(2) + return releaseLock() + }) + } + return this.LockManager.runWithLock(this.key, runner, () => { + this.logger.debug.callCount.should.equal(0) + return done() + }) + }) + + return it('logs that the excecution exceeded the lock', function (done) { + const runner = (extendLock, releaseLock) => { + this.clock.tick(this.LOCK_TTL_MS + 1) + return releaseLock() + } + return this.LockManager.runWithLock(this.key, runner, () => { + const slowExecutionError = new Error('slow execution during lock') + this.logger.debug + .calledWithMatch('exceeded lock timeout', { key: this.key }) + .should.equal(true) + return done() + }) + }) + }) + + return describe('releaseLock', function () { + describe('when the lock is current', function () { + beforeEach(function () { + this.rclient.eval.yields(null, 1) + return this.LockManager.releaseLock( + this.key, + this.lockValue, + this.callback + ) + }) + + it('should clear the data from redis', function () { + return this.rclient.eval + .calledWith( + this.LockManager.UNLOCK_SCRIPT, + 1, + this.key, + this.lockValue + ) + .should.equal(true) + }) + + return it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + }) + + return describe('when the lock has expired', function () { + beforeEach(function () { + this.rclient.eval.yields(null, 0) + return this.LockManager.releaseLock( + this.key, + this.lockValue, + this.callback + ) + }) + + return it('should return an error if the lock has expired', function () { + return this.callback + .calledWith( + sinon.match.has('message', 'tried to release timed out lock') + ) + .should.equal(true) + }) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/OperationsCompressor/OperationsCompressorTests.js b/services/project-history/test/unit/js/OperationsCompressor/OperationsCompressorTests.js new file mode 100644 index 0000000000..777ae0e5bf --- /dev/null +++ b/services/project-history/test/unit/js/OperationsCompressor/OperationsCompressorTests.js @@ -0,0 +1,76 @@ +import { expect } from 'chai' +import Core from 'overleaf-editor-core' +import * as OperationsCompressor from '../../../../app/js/OperationsCompressor.js' + +describe('OperationsCompressor', function () { + function edit(pathname, textOperationJsonObject) { + return Core.Operation.editFile( + pathname, + Core.TextOperation.fromJSON(textOperationJsonObject) + ) + } + + it('collapses edit operations', function () { + const compressedOperations = OperationsCompressor.compressOperations([ + edit('main.tex', [3, 'foo', 17]), + edit('main.tex', [10, -5, 8]), + ]) + + expect(compressedOperations).to.have.length(1) + expect(compressedOperations[0]).to.deep.equal( + edit('main.tex', [3, 'foo', 4, -5, 8]) + ) + }) + + it('only collapses consecutive composable edit operations', function () { + const compressedOperations = OperationsCompressor.compressOperations([ + edit('main.tex', [3, 'foo', 17]), + edit('main.tex', [10, -5, 8]), + edit('not-main.tex', [3, 'foo', 17]), + edit('not-main.tex', [10, -5, 8]), + ]) + + expect(compressedOperations).to.have.length(2) + expect(compressedOperations[0]).to.deep.equal( + edit('main.tex', [3, 'foo', 4, -5, 8]) + ) + expect(compressedOperations[1]).to.deep.equal( + edit('not-main.tex', [3, 'foo', 4, -5, 8]) + ) + }) + + it("don't collapses text operations around non-composable operations", function () { + const compressedOperations = OperationsCompressor.compressOperations([ + edit('main.tex', [3, 'foo', 17]), + Core.Operation.moveFile('main.tex', 'new-main.tex'), + edit('new-main.tex', [10, -5, 8]), + edit('new-main.tex', [6, 'bar', 12]), + ]) + + expect(compressedOperations).to.have.length(3) + expect(compressedOperations[0]).to.deep.equal( + edit('main.tex', [3, 'foo', 17]) + ) + expect(compressedOperations[1].newPathname).to.deep.equal('new-main.tex') + expect(compressedOperations[2]).to.deep.equal( + edit('new-main.tex', [6, 'bar', 4, -5, 8]) + ) + }) + + it('handle empty operations', function () { + const compressedOperations = OperationsCompressor.compressOperations([]) + + expect(compressedOperations).to.have.length(0) + }) + + it('handle single operations', function () { + const compressedOperations = OperationsCompressor.compressOperations([ + edit('main.tex', [3, 'foo', 17]), + ]) + + expect(compressedOperations).to.have.length(1) + expect(compressedOperations[0]).to.deep.equal( + edit('main.tex', [3, 'foo', 17]) + ) + }) +}) diff --git a/services/project-history/test/unit/js/RedisManager/RedisManagerTests.js b/services/project-history/test/unit/js/RedisManager/RedisManagerTests.js new file mode 100644 index 0000000000..0b4e1a6051 --- /dev/null +++ b/services/project-history/test/unit/js/RedisManager/RedisManagerTests.js @@ -0,0 +1,818 @@ +/* eslint-disable + camelcase, + mocha/no-identical-title, + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/RedisManager.js' + +describe('RedisManager', function () { + beforeEach(async function () { + this.rclient = { + auth: sinon.stub(), + exec: sinon.stub().yields(), + lrange: sinon.stub(), + lrem: sinon.stub(), + srem: sinon.stub(), + } + this.rclient.multi = sinon.stub().returns(this.rclient) + this.RedisWrapper = { + createClient: sinon.stub().returns(this.rclient), + } + this.Settings = { + redis: { + project_history: { + key_schema: { + projectHistoryOps({ project_id }) { + return `Project:HistoryOps:${project_id}` + }, + }, + }, + }, + } + this.Metrics = { + timing: sinon.stub(), + summary: sinon.stub(), + globalGauge: sinon.stub(), + } + this.RedisManager = await esmock(MODULE_PATH, { + '@overleaf/redis-wrapper': this.RedisWrapper, + '@overleaf/settings': this.Settings, + '@overleaf/metrics': this.Metrics, + }) + + this.project_id = 'project-id-123' + this.batch_size = 100 + + this.updates = [ + { v: 42, op: 'mock-op-42' }, + { v: 45, op: 'mock-op-45' }, + ] + this.json_updates = Array.from(this.updates).map(update => + JSON.stringify(update) + ) + + return (this.callback = sinon.stub()) + }) + + describe('getOldestDocUpdates', function () { + beforeEach(function () { + this.rclient.lrange.yields(null, this.json_updates) + return this.RedisManager.getOldestDocUpdates( + this.project_id, + this.batch_size, + this.callback + ) + }) + + it('should read the updates from redis', function () { + return this.rclient.lrange + .calledWith( + `Project:HistoryOps:${this.project_id}`, + 0, + this.batch_size - 1 + ) + .should.equal(true) + }) + + return it('should call the callback with the unparsed ops', function () { + return this.callback + .calledWith(null, this.json_updates) + .should.equal(true) + }) + }) + + describe('parseDocUpdates', function () { + beforeEach(function () { + return this.RedisManager.parseDocUpdates(this.json_updates, this.callback) + }) + + return it('should call the callback with the parsed ops', function () { + return this.callback.calledWith(null, this.updates).should.equal(true) + }) + }) + + describe('deleteAppliedDocUpdates', function () { + beforeEach(function () { + return this.RedisManager.deleteAppliedDocUpdates( + this.project_id, + this.json_updates, + this.callback + ) + }) + + it('should delete the first update from redis', function () { + this.rclient.lrem.should.have.been.calledWith( + `Project:HistoryOps:${this.project_id}`, + 1, + this.json_updates[0] + ) + }) + + it('should delete the second update from redis', function () { + return this.rclient.lrem + .calledWith( + `Project:HistoryOps:${this.project_id}`, + 1, + this.json_updates[1] + ) + .should.equal(true) + }) + + return it('should call the callback ', function () { + return this.callback.called.should.equal(true) + }) + }) + + return describe('getUpdatesInBatches', function () { + beforeEach(function () { + this.rawUpdates = ['raw-update-1', 'raw-update-2'] + this.expandedUpdates = ['expanded-update-1', 'expanded-update-2'] + this.RedisManager._mocks.deleteAppliedDocUpdates = sinon.stub().yields() + + this.isProjectHistoryEnabled = true + return (this.runner = sinon + .stub() + .yields(null, this.isProjectHistoryEnabled)) + }) + + describe('single batch smaller than batch size', function () { + beforeEach(function (done) { + this.RedisManager._mocks.getOldestDocUpdates = sinon + .stub() + .yields(null, this.rawUpdates) + this.RedisManager._mocks.parseDocUpdates = sinon + .stub() + .yields(null, this.expandedUpdates) + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 3, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a single batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 1 + ) + }) + + it('calls the runner once', function () { + return this.runner.callCount.should.equal(1) + }) + + it('calls the runner with the updates', function () { + return this.runner.calledWith(this.expandedUpdates).should.equal(true) + }) + + it('deletes the applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals(this.rawUpdates) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('single batch at batch size', function () { + beforeEach(function (done) { + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates) + this.RedisManager._mocks.getOldestDocUpdates.onCall(1).yields(null, []) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner once', function () { + return this.runner.callCount.should.equal(1) + }) + + it('calls the runner with the updates', function () { + return this.runner.calledWith(this.expandedUpdates).should.equal(true) + }) + + it('deletes the applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals(this.rawUpdates) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('single batch exceeding size limit on updates', function () { + beforeEach(function (done) { + this.rawUpdates0 = ['raw-update-1-12345678', 'raw-update-2-12345678'] + this.rawUpdates1 = ['raw-update-2-12345678'] + this.expandedUpdates0 = ['expanded-update-1'] + this.expandedUpdates1 = ['expanded-update-2'] + // set the threshold below the size of the first update + this.RedisManager.setRawUpdateSizeThreshold( + this.rawUpdates0[0].length - 1 + ) + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates0) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, this.rawUpdates1) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates0) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, this.expandedUpdates1) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the first updates', function () { + return this.runner.calledWith(this.expandedUpdates0).should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[0]]) + ) + .should.equal(true) + }) + + it('calls the runner with the second updates', function () { + return this.runner.calledWith(this.expandedUpdates1).should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[1]]) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('two batches with first update below and second update above the size limit on updates', function () { + beforeEach(function (done) { + this.rawUpdates0 = ['raw-update-1', 'raw-update-2-12345678'] + this.rawUpdates1 = ['raw-update-2-12345678'] + this.expandedUpdates0 = ['expanded-update-1'] + this.expandedUpdates1 = ['expanded-update-2'] + // set the threshold above the size of the first update, but below the total size + this.RedisManager.setRawUpdateSizeThreshold( + this.rawUpdates0[0].length + 1 + ) + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates0) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, this.rawUpdates1) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates0) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, this.expandedUpdates1) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the first update', function () { + return this.runner.calledWith(this.expandedUpdates0).should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[0]]) + ) + .should.equal(true) + }) + + it('calls the runner with the second update', function () { + return this.runner.calledWith(this.expandedUpdates1).should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[1]]) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('single batch exceeding op count limit on updates', function () { + beforeEach(function (done) { + this.rawUpdates0 = [ + "{op: ['a', 'b', 'c', 'd']}", + "{op:['e', 'f', 'g', 'h']}", + ] + this.rawUpdates1 = ["{op:['e', 'f', 'g', 'h']}"] + this.expandedUpdates0 = [ + { op: ['a', 'b', 'c', 'd'] }, + { op: ['e', 'f', 'g', 'h'] }, + ] + this.expandedUpdates1 = [{ op: ['e', 'f', 'g', 'h'] }] + // set the threshold below the size of the first update + this.RedisManager.setMaxUpdateOpLength( + this.expandedUpdates0[0].op.length - 1 + ) + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates0) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, this.rawUpdates1) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates0) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, this.expandedUpdates1) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the first updates', function () { + return this.runner + .calledWith([this.expandedUpdates0[0]]) + .should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[0]]) + ) + .should.equal(true) + }) + + it('calls the runner with the second updates', function () { + return this.runner.calledWith(this.expandedUpdates1).should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[1]]) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('single batch exceeding doc content count', function () { + beforeEach(function (done) { + this.rawUpdates0 = [ + '{resyncDocContent: 123}', + '{resyncDocContent: 456}', + ] + this.rawUpdates1 = ['{resyncDocContent: 456}'] + this.expandedUpdates0 = [ + { resyncDocContent: 123 }, + { resyncDocContent: 456 }, + ] + this.expandedUpdates1 = [{ resyncDocContent: 456 }] + // set the threshold below the size of the first update + this.RedisManager.setMaxNewDocContentCount( + this.expandedUpdates0.length - 1 + ) + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates0) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, this.rawUpdates1) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates0) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, this.expandedUpdates1) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the first updates', function () { + return this.runner + .calledWith([this.expandedUpdates0[0]]) + .should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[0]]) + ) + .should.equal(true) + }) + + it('calls the runner with the second updates', function () { + return this.runner.calledWith(this.expandedUpdates1).should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[1]]) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('two batches with first update below and second update above the size limit on updates', function () { + beforeEach(function (done) { + this.rawUpdates0 = [ + "{op: ['a', 'b', 'c', 'd']}", + "{op:['e', 'f', 'g', 'h']}", + ] + this.rawUpdates1 = ["{op:['e', 'f', 'g', 'h']}"] + this.expandedUpdates0 = [ + { op: ['a', 'b', 'c', 'd'] }, + { op: ['e', 'f', 'g', 'h'] }, + ] + this.expandedUpdates1 = [{ op: ['e', 'f', 'g', 'h'] }] + // set the threshold below the size of the first update + this.RedisManager.setMaxUpdateOpLength( + this.expandedUpdates0[0].op.length + 1 + ) + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates0) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, this.rawUpdates1) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates0) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, this.expandedUpdates1) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the first updates', function () { + return this.runner + .calledWith([this.expandedUpdates0[0]]) + .should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[0]]) + ) + .should.equal(true) + }) + + it('calls the runner with the second updates', function () { + return this.runner.calledWith(this.expandedUpdates1).should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals([this.rawUpdates0[1]]) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('two batches', function () { + beforeEach(function (done) { + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(null, ['raw-update-3']) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates) + this.RedisManager._mocks.parseDocUpdates + .onCall(1) + .yields(null, ['expanded-update-3']) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('requests a second batch of updates', function () { + return this.RedisManager._mocks.getOldestDocUpdates.callCount.should.equal( + 2 + ) + }) + + it('calls the runner twice', function () { + return this.runner.callCount.should.equal(2) + }) + + it('calls the runner with the updates', function () { + return this.runner.calledWith(this.expandedUpdates).should.equal(true) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals(this.rawUpdates) + ) + .should.equal(true) + }) + + it('deletes the second set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals(['raw-update-3']) + ) + .should.equal(true) + }) + + return it('calls the callback with the result of the runner', function () { + return this.callback + .calledWith(null, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + + describe('error when first reading updates', function () { + beforeEach(function (done) { + this.error = new Error('error') + this.RedisManager._mocks.getOldestDocUpdates = sinon + .stub() + .yields(this.error) + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('does not delete any updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates.called.should.equal( + false + ) + }) + + return it('calls the callback with the error', function () { + return this.callback + .calledWith(this.error, undefined) + .should.equal(true) + }) + }) + + return describe('error when reading updates for a second batch', function () { + beforeEach(function (done) { + this.error = new Error('error') + this.RedisManager._mocks.getOldestDocUpdates = sinon.stub() + this.RedisManager._mocks.getOldestDocUpdates + .onCall(0) + .yields(null, this.rawUpdates) + this.RedisManager._mocks.getOldestDocUpdates + .onCall(1) + .yields(this.error) + this.RedisManager._mocks.parseDocUpdates = sinon.stub() + this.RedisManager._mocks.parseDocUpdates + .onCall(0) + .yields(null, this.expandedUpdates) + + return this.RedisManager.getUpdatesInBatches( + this.project_id, + 2, + this.runner, + (error, isProjectHistoryEnabled) => { + this.callback(error, isProjectHistoryEnabled) + return done() + } + ) + }) + + it('deletes the first set of applied updates', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates + .calledWith( + this.project_id, + sinon.match.array.deepEquals(this.rawUpdates) + ) + .should.equal(true) + }) + + it('deletes applied updates only once', function () { + return this.RedisManager._mocks.deleteAppliedDocUpdates.callCount.should.equal( + 1 + ) + }) + + return it('calls the callback with the error and the first result of the runner', function () { + return this.callback + .calledWith(this.error, this.isProjectHistoryEnabled) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/RetryManager/RetryManagerTests.js b/services/project-history/test/unit/js/RetryManager/RetryManagerTests.js new file mode 100644 index 0000000000..0bd6e301f9 --- /dev/null +++ b/services/project-history/test/unit/js/RetryManager/RetryManagerTests.js @@ -0,0 +1,144 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { ObjectId } from 'mongodb' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/RetryManager.js' + +describe('RetryManager', function () { + beforeEach(async function () { + this.projectId1 = new ObjectId().toString() + this.projectId2 = new ObjectId().toString() + this.projectId3 = new ObjectId().toString() + this.projectId4 = new ObjectId().toString() + this.historyId = 12345 + + this.WebApiManager = { + promises: { + getHistoryId: sinon.stub().resolves(this.historyId), + }, + } + this.RedisManager = { + promises: { + countUnprocessedUpdates: sinon.stub().resolves(0), + }, + } + this.ErrorRecorder = { + promises: { + getFailedProjects: sinon.stub().resolves([ + { + project_id: this.projectId1, + error: 'Error: Timeout', + attempts: 1, + }, + { + project_id: this.projectId2, + error: 'Error: Timeout', + attempts: 25, + }, + { + project_id: this.projectId3, + error: 'sync ongoing', + attempts: 10, + resyncAttempts: 1, + }, + { + project_id: this.projectId4, + error: 'sync ongoing', + attempts: 10, + resyncAttempts: 2, + }, + ]), + getFailureRecord: sinon.stub().resolves(), + }, + } + this.SyncManager = { + promises: { + startResync: sinon.stub().resolves(), + startHardResync: sinon.stub().resolves(), + }, + } + this.UpdatesProcessor = { + promises: { + processUpdatesForProject: sinon.stub().resolves(), + }, + } + this.settings = { + redis: { + lock: { + key_schema: { + projectHistoryLock({ projectId }) { + return `ProjectHistoryLock:${projectId}` + }, + }, + }, + }, + } + this.request = {} + this.RetryManager = await esmock(MODULE_PATH, { + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/RedisManager.js': this.RedisManager, + '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder, + '../../../../app/js/SyncManager.js': this.SyncManager, + '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor, + '@overleaf/settings': this.settings, + request: this.request, + }) + }) + + describe('RetryManager', function () { + describe('for a soft failure', function () { + beforeEach(async function () { + await this.RetryManager.promises.retryFailures({ failureType: 'soft' }) + }) + + it('should flush the queue', function () { + expect( + this.UpdatesProcessor.promises.processUpdatesForProject + ).to.have.been.calledWith(this.projectId1) + }) + }) + + describe('for a hard failure', function () { + beforeEach(async function () { + await this.RetryManager.promises.retryFailures({ failureType: 'hard' }) + }) + + it('should check the overleaf project id', function () { + expect( + this.WebApiManager.promises.getHistoryId + ).to.have.been.calledWith(this.projectId2) + }) + + it("should start a soft resync when a resync hasn't been tried yet", function () { + expect(this.SyncManager.promises.startResync).to.have.been.calledWith( + this.projectId2 + ) + }) + + it('should start a hard resync when a resync has already been tried', function () { + expect( + this.SyncManager.promises.startHardResync + ).to.have.been.calledWith(this.projectId3) + }) + + it("shouldn't try a resync after a hard resync attempt failed", function () { + expect( + this.SyncManager.promises.startHardResync + ).not.to.have.been.calledWith(this.projectId4) + }) + + it('should count the unprocessed updates', function () { + expect( + this.RedisManager.promises.countUnprocessedUpdates + ).to.have.been.calledWith(this.projectId2) + }) + + it('should check the failure record', function () { + expect( + this.ErrorRecorder.promises.getFailureRecord + ).to.have.been.calledWith(this.projectId2) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js b/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js new file mode 100644 index 0000000000..758dddbb18 --- /dev/null +++ b/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js @@ -0,0 +1,501 @@ +/* eslint-disable + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' +import Core from 'overleaf-editor-core' +import BPromise from 'bluebird' +import * as Errors from '../../../../app/js/Errors.js' + +const MODULE_PATH = '../../../../app/js/SnapshotManager.js' + +describe('SnapshotManager', function () { + beforeEach(async function () { + this.HistoryStoreManager = { + getBlobStore: sinon.stub(), + getChunkAtVersion: sinon.stub(), + getMostRecentChunk: sinon.stub(), + getProjectBlobStream: sinon.stub(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.SnapshotManager = await esmock(MODULE_PATH, { + 'overleaf-editor-core': Core, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/Errors.js': Errors, + }) + this.projectId = 'project-id-123' + this.historyId = 'ol-project-id-123' + return (this.callback = sinon.stub()) + }) + + describe('getFileSnapshotStream', function () { + beforeEach(function () { + this.WebApiManager.getHistoryId.yields(null, this.historyId) + return this.HistoryStoreManager.getChunkAtVersion.yields(null, { + chunk: { + history: { + snapshot: { + files: { + 'main.tex': { + hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea', + stringLength: 41, + }, + 'binary.png': { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [41, '\n\nSeven eight'], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'main.tex', + textOperation: [54, ' nine'], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + authors: [ + { + id: 31, + email: 'james.allen@overleaf.com', + name: 'James', + }, + ], + }, + }) + }) + + describe('of a text file', function () { + beforeEach(function (done) { + this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({ + getString: BPromise.promisify( + (this.getString = sinon.stub().yields( + null, + `\ +Hello world + +One two three + +Four five six\ +`.replace(/^\t/g, '') + )) + ), + }) + this.SnapshotManager.getFileSnapshotStream( + this.projectId, + 5, + 'main.tex', + (error, stream) => { + this.stream = stream + return done(error) + } + ) + }) + + it('should get the overleaf id', function () { + return this.WebApiManager.getHistoryId + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the chunk', function () { + return this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, 5) + .should.equal(true) + }) + + it('should get the blob of the starting snapshot', function () { + return this.getString + .calledWith('35c9bd86574d61dcadbce2fdd3d4a0684272c6ea') + .should.equal(true) + }) + + it('should return a string stream with the text content', function () { + return expect(this.stream.read().toString()).to.equal( + `\ +Hello world + +One two three + +Four five six + +Seven eight nine\ +`.replace(/^\t/g, '') + ) + }) + + describe('on blob store error', function () { + beforeEach(function () { + this.error = new Error('ESOCKETTIMEDOUT') + this.HistoryStoreManager.getBlobStore + .withArgs(this.historyId) + .returns({ + getString: BPromise.promisify(sinon.stub().throws(this.error)), + }) + }) + + it('should call back with error', function (done) { + this.SnapshotManager.getFileSnapshotStream( + this.projectId, + 5, + 'main.tex', + error => { + expect(error).to.exist + expect(error.name).to.equal(this.error.name) + done() + } + ) + }) + }) + }) + + describe('of a binary file', function () { + beforeEach(function (done) { + this.HistoryStoreManager.getProjectBlobStream + .withArgs(this.historyId) + .yields(null, (this.stream = 'mock-stream')) + return this.SnapshotManager.getFileSnapshotStream( + this.projectId, + 5, + 'binary.png', + (error, returnedStream) => { + this.returnedStream = returnedStream + return done(error) + } + ) + }) + + it('should get the overleaf id', function () { + return this.WebApiManager.getHistoryId + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the chunk', function () { + return this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, 5) + .should.equal(true) + }) + + it('should get the blob of the starting snapshot', function () { + return this.HistoryStoreManager.getProjectBlobStream + .calledWith( + this.historyId, + 'c6654ea913979e13e22022653d284444f284a172' + ) + .should.equal(true) + }) + + return it('should return a stream with the blob content', function () { + return expect(this.returnedStream).to.equal(this.stream) + }) + }) + + return describe("when the file doesn't exist", function () { + beforeEach(function (done) { + return this.SnapshotManager.getFileSnapshotStream( + this.projectId, + 5, + 'not-here.png', + (error, returnedStream) => { + this.error = error + this.returnedStream = returnedStream + return done() + } + ) + }) + + return it('should return a NotFoundError', function () { + expect(this.error).to.exist + expect(this.error.message).to.equal('not-here.png not found') + return expect(this.error).to.be.an.instanceof(Errors.NotFoundError) + }) + }) + }) + + describe('getProjectSnapshot', function () { + beforeEach(function () { + this.WebApiManager.getHistoryId.yields(null, this.historyId) + return this.HistoryStoreManager.getChunkAtVersion.yields(null, { + chunk: (this.chunk = { + history: { + snapshot: { + files: { + 'main.tex': { + hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea', + stringLength: 41, + }, + 'unchanged.tex': { + hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea', + stringLength: 41, + }, + 'binary.png': { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [41, '\n\nSeven eight'], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'main.tex', + textOperation: [54, ' nine'], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + authors: [ + { + id: 31, + email: 'james.allen@overleaf.com', + name: 'James', + }, + ], + }), + }) + }) + + describe('of project', function () { + beforeEach(function (done) { + this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({ + getString: BPromise.promisify( + (this.getString = sinon.stub().yields( + null, + `\ +Hello world + +One two three + +Four five six\ +`.replace(/^\t/g, '') + )) + ), + }) + this.SnapshotManager.getProjectSnapshot( + this.projectId, + 5, + (error, data) => { + this.data = data + done(error) + } + ) + }) + + it('should get the overleaf id', function () { + return this.WebApiManager.getHistoryId + .calledWith(this.projectId) + .should.equal(true) + }) + + it('should get the chunk', function () { + return this.HistoryStoreManager.getChunkAtVersion + .calledWith(this.projectId, this.historyId, 5) + .should.equal(true) + }) + + return it('should produce the snapshot file data', function () { + expect(this.data).to.have.all.keys(['files', 'projectId']) + expect(this.data.projectId).to.equal('project-id-123') + expect(this.data.files['main.tex']).to.exist + expect(this.data.files['unchanged.tex']).to.exist + expect(this.data.files['binary.png']).to.exist + // files with operations in the chunk should return content only + expect(this.data.files['main.tex'].data.content).to.equal( + 'Hello world\n\nOne two three\n\nFour five six\n\nSeven eight nine' + ) + expect(this.data.files['main.tex'].data.hash).to.not.exist + // unchanged files in the chunk should return hash only + expect(this.data.files['unchanged.tex'].data.hash).to.equal( + '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea' + ) + expect(this.data.files['unchanged.tex'].data.content).to.not.exist + return expect(this.data.files['binary.png'].data.hash).to.equal( + 'c6654ea913979e13e22022653d284444f284a172' + ) + }) + }) + + describe('on blob store error', function () { + beforeEach(function () { + this.error = new Error('ESOCKETTIMEDOUT') + this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({ + getString: BPromise.promisify(sinon.stub().yields(this.error)), + }) + }) + + it('should call back with error', function (done) { + this.SnapshotManager.getProjectSnapshot(this.projectId, 5, error => { + expect(error).to.exist + expect(error.message).to.equal(this.error.message) + + done() + }) + }) + }) + }) + + return describe('getLatestSnapshot', function () { + describe('for a project', function () { + beforeEach(function (done) { + this.HistoryStoreManager.getMostRecentChunk.yields(null, { + chunk: (this.chunk = { + history: { + snapshot: { + files: { + 'main.tex': { + hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea', + stringLength: 41, + }, + 'binary.png': { + hash: 'c6654ea913979e13e22022653d284444f284a172', + byteLength: 41, + }, + }, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + textOperation: [41, '\n\nSeven eight'], + }, + ], + timestamp: '2017-12-04T10:29:17.786Z', + authors: [31], + }, + { + operations: [ + { + pathname: 'main.tex', + textOperation: [54, ' nine'], + }, + ], + timestamp: '2017-12-04T10:29:22.905Z', + authors: [31], + }, + ], + }, + startVersion: 3, + authors: [ + { + id: 31, + email: 'james.allen@overleaf.com', + name: 'James', + }, + ], + }), + }) + + this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({ + getString: BPromise.promisify( + (this.getString = sinon.stub().yields( + null, + `\ +Hello world + +One two three + +Four five six\ +`.replace(/^\t/g, '') + )) + ), + }) + this.SnapshotManager.getLatestSnapshot( + this.projectId, + this.historyId, + (error, data) => { + this.data = data + done(error) + } + ) + }) + + it('should get the chunk', function () { + return this.HistoryStoreManager.getMostRecentChunk + .calledWith(this.projectId, this.historyId) + .should.equal(true) + }) + + return it('should produce the snapshot file data', function () { + expect(this.data).to.have.all.keys(['main.tex', 'binary.png']) + expect(this.data['main.tex']).to.exist + expect(this.data['binary.png']).to.exist + expect(this.data['main.tex'].getStringLength()).to.equal(59) + expect(this.data['binary.png'].getByteLength()).to.equal(41) + return expect(this.data['binary.png'].getHash()).to.equal( + 'c6654ea913979e13e22022653d284444f284a172' + ) + }) + }) + + return describe('when the chunk is empty', function () { + beforeEach(function (done) { + this.HistoryStoreManager.getMostRecentChunk.yields(null) + return this.SnapshotManager.getLatestSnapshot( + this.projectId, + this.historyId, + (error, data) => { + this.error = error + this.data = data + return done() + } + ) + }) + + it('should get the chunk', function () { + return this.HistoryStoreManager.getMostRecentChunk + .calledWith(this.projectId, this.historyId) + .should.equal(true) + }) + + return it('return an error', function () { + expect(this.error).to.exist + return expect(this.error.message).to.equal('undefined chunk') + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js b/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js new file mode 100644 index 0000000000..c2c3d3e892 --- /dev/null +++ b/services/project-history/test/unit/js/SummarizedUpdatesManager/SummarizedUpdatesManagerTests.js @@ -0,0 +1,845 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/SummarizedUpdatesManager.js' + +// A sufficiently large amount of time to make the algorithm process updates +// separately +const LATER = 1000000 + +describe('SummarizedUpdatesManager', function () { + beforeEach(async function () { + this.historyId = 'history-id-123' + this.projectId = 'project-id-123' + this.firstChunk = { chunk: { startVersion: 0 } } + this.secondChunk = { chunk: { startVersion: 1 } } + + this.ChunkTranslator = { + convertToSummarizedUpdates: sinon.stub(), + } + this.HistoryApiManager = { + shouldUseProjectHistory: sinon.stub().yields(null, true), + } + this.HistoryStoreManager = { + getMostRecentChunk: sinon.stub(), + getChunkAtVersion: sinon.stub(), + } + this.UpdatesProcessor = { + processUpdatesForProject: sinon.stub().withArgs(this.projectId).yields(), + } + this.WebApiManager = { + getHistoryId: sinon.stub().yields(null, this.historyId), + } + this.LabelsManager = { + getLabels: sinon.stub().yields(null, []), + } + this.SummarizedUpdatesManager = await esmock(MODULE_PATH, { + '../../../../app/js/ChunkTranslator.js': this.ChunkTranslator, + '../../../../app/js/HistoryApiManager.js': this.HistoryApiManager, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/LabelsManager.js': this.LabelsManager, + }) + this.callback = sinon.stub() + }) + + describe('getSummarizedProjectUpdates', function () { + describe('chunk management', function () { + describe('when there is a single empty chunk', function () { + setupChunks([[]]) + expectSummaries('returns an empty list of updates', {}, []) + }) + + describe('when there is a single non-empty chunk', function () { + setupChunks([[makeUpdate()]]) + expectSummaries('returns summarized updates', {}, [makeSummary()]) + }) + + describe('when there are multiple chunks', function () { + setupChunks([ + [makeUpdate({ startTs: 0, v: 1 })], + [makeUpdate({ startTs: LATER, v: 2 })], + ]) + + describe('and requesting many summaries', function () { + expectSummaries('returns many update summaries', {}, [ + makeSummary({ startTs: LATER, fromV: 2 }), + makeSummary({ startTs: 0, fromV: 1 }), + ]) + }) + + describe('and requesting a single summary', function () { + expectSummaries('returns a single update summary', { min_count: 1 }, [ + makeSummary({ startTs: LATER, fromV: 2 }), + ]) + }) + }) + + describe('when there are too many chunks', function () { + // Set up 10 chunks + const chunks = [] + for (let v = 1; v <= 10; v++) { + chunks.push([ + makeUpdate({ + startTs: v * 100, // values: 100 - 1000 + v, // values: 1 - 10 + }), + ]) + } + setupChunks(chunks) + + // Verify that we stop summarizing after 5 chunks + expectSummaries('summarizes the 5 latest chunks', {}, [ + makeSummary({ startTs: 600, endTs: 1010, fromV: 6, toV: 11 }), + ]) + }) + + describe('when requesting updates before a specific version', function () { + // Chunk 1 contains 5 updates that were made close to each other and 5 + // other updates that were made later. + const chunk1 = [] + for (let v = 1; v <= 5; v++) { + chunk1.push( + makeUpdate({ + startTs: v * 100, // values: 100 - 500 + v, // values: 1 - 5 + }) + ) + } + for (let v = 6; v <= 10; v++) { + chunk1.push( + makeUpdate({ + startTs: LATER + v * 100, // values: 1000600 - 1001000 + v, // values: 6 - 10 + }) + ) + } + + // Chunk 2 contains 5 updates that were made close to the latest updates in + // chunk 1. + const chunk2 = [] + for (let v = 11; v <= 15; v++) { + chunk2.push( + makeUpdate({ + startTs: LATER + v * 100, // values: 1001100 - 1001500 + v, // values: 11 - 15 + }) + ) + } + setupChunks([chunk1, chunk2]) + + expectSummaries( + 'summarizes the updates in a single chunk if the chunk is sufficient', + { before: 14, min_count: 1 }, + [ + makeSummary({ + startTs: LATER + 1100, + endTs: LATER + 1310, + fromV: 11, + toV: 14, + }), + ] + ) + + expectSummaries( + 'summarizes the updates in many chunks otherwise', + { before: 14, min_count: 2 }, + [ + makeSummary({ + startTs: LATER + 600, + endTs: LATER + 1310, + fromV: 6, + toV: 14, + }), + makeSummary({ + startTs: 100, + endTs: 510, + fromV: 1, + toV: 6, + }), + ] + ) + }) + }) + + describe('update summarization', function () { + describe('updates that are close in time', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: ['user2'], + startTs: 20, + v: 5, + }), + ], + ]) + + expectSummaries('should merge the updates', {}, [ + makeSummary({ + users: ['user1', 'user2'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('updates that are far apart in time', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 100, + v: 4, + }), + makeUpdate({ + users: ['user2'], + startTs: LATER, + v: 5, + }), + ], + ]) + + expectSummaries('should not merge the updates', {}, [ + makeSummary({ + users: ['user2'], + startTs: LATER, + endTs: LATER + 10, + fromV: 5, + toV: 6, + }), + makeSummary({ + users: ['user1'], + startTs: 100, + endTs: 110, + fromV: 4, + toV: 5, + }), + ]) + }) + + describe('mergeable updates in different chunks', function () { + setupChunks([ + [ + makeUpdate({ + pathnames: ['main.tex'], + users: ['user1'], + startTs: 10, + v: 4, + }), + makeUpdate({ + pathnames: ['main.tex'], + users: ['user2'], + startTs: 30, + v: 5, + }), + ], + [ + makeUpdate({ + pathnames: ['chapter.tex'], + users: ['user1'], + startTs: 40, + v: 6, + }), + makeUpdate({ + pathnames: ['chapter.tex'], + users: ['user1'], + startTs: 50, + v: 7, + }), + ], + ]) + + expectSummaries('should merge the updates', {}, [ + makeSummary({ + pathnames: ['main.tex', 'chapter.tex'], + users: ['user1', 'user2'], + startTs: 10, + endTs: 60, + fromV: 4, + toV: 8, + }), + ]) + }) + + describe('null user values after regular users', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: [null], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should include the null values', {}, [ + makeSummary({ + users: [null, 'user1'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('null user values before regular users', function () { + setupChunks([ + [ + makeUpdate({ + users: [null], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: ['user1'], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should include the null values', {}, [ + makeSummary({ + users: [null, 'user1'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('multiple null user values', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 10, + v: 4, + }), + makeUpdate({ + users: [null], + startTs: 20, + v: 5, + }), + makeUpdate({ + users: [null], + startTs: 70, + v: 6, + }), + ], + ]) + expectSummaries('should merge the null values', {}, [ + makeSummary({ + users: [null, 'user1'], + startTs: 10, + endTs: 80, + fromV: 4, + toV: 7, + }), + ]) + }) + + describe('multiple users', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: ['user2'], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should merge the users', {}, [ + makeSummary({ + users: ['user1', 'user2'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('duplicate updates with the same v1 user', function () { + setupChunks([ + [ + makeUpdate({ + users: [{ id: 'user1' }], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: [{ id: 'user1' }], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should deduplicate the users', {}, [ + makeSummary({ + users: [{ id: 'user1' }], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('duplicate updates with the same v2 user', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: ['user1'], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should deduplicate the users', {}, [ + makeSummary({ + users: ['user1'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('mixed v1 and v2 users with the same id', function () { + setupChunks([ + [ + makeUpdate({ + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + users: [{ id: 'user1' }], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should deduplicate the users', {}, [ + makeSummary({ + users: [{ id: 'user1' }], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('project ops in mergeable updates', function () { + setupChunks([ + [ + makeUpdate({ + pathnames: [], + projectOps: [ + { rename: { pathname: 'C.tex', newPathname: 'D.tex' } }, + ], + users: ['user2'], + startTs: 0, + v: 4, + }), + makeUpdate({ + pathnames: [], + projectOps: [ + { rename: { pathname: 'A.tex', newPathname: 'B.tex' } }, + ], + users: ['user1'], + startTs: 20, + v: 5, + }), + ], + ]) + expectSummaries('should merge project ops', {}, [ + makeSummary({ + pathnames: [], + projectOps: [ + { + atV: 5, + rename: { + pathname: 'A.tex', + newPathname: 'B.tex', + }, + }, + { + atV: 4, + rename: { + pathname: 'C.tex', + newPathname: 'D.tex', + }, + }, + ], + users: ['user1', 'user2'], + startTs: 0, + endTs: 30, + fromV: 4, + toV: 6, + }), + ]) + }) + + describe('mergable updates with a mix of project ops and doc ops', function () { + setupChunks([ + [ + makeUpdate({ + pathnames: ['main.tex'], + users: ['user1'], + startTs: 0, + v: 4, + }), + makeUpdate({ + pathnames: [], + users: ['user2'], + projectOps: [ + { rename: { pathname: 'A.tex', newPathname: 'B.tex' } }, + ], + startTs: 20, + v: 5, + }), + makeUpdate({ + pathnames: ['chapter.tex'], + users: ['user2'], + startTs: 40, + v: 6, + }), + ], + ]) + expectSummaries('should keep updates separate', {}, [ + makeSummary({ + pathnames: ['chapter.tex'], + users: ['user2'], + startTs: 40, + fromV: 6, + }), + makeSummary({ + pathnames: [], + users: ['user2'], + projectOps: [ + { atV: 5, rename: { pathname: 'A.tex', newPathname: 'B.tex' } }, + ], + startTs: 20, + fromV: 5, + }), + makeSummary({ + pathnames: ['main.tex'], + users: ['user1'], + startTs: 0, + fromV: 4, + }), + ]) + }) + + describe('label on an update', function () { + const label = { + id: 'mock-id', + comment: 'an example comment', + version: 5, + } + setupChunks([ + [ + makeUpdate({ startTs: 0, v: 3 }), + makeUpdate({ startTs: 20, v: 4 }), + makeUpdate({ startTs: 40, v: 5 }), + makeUpdate({ startTs: 60, v: 6 }), + ], + ]) + setupLabels([label]) + + expectSummaries('should split the updates at the label', {}, [ + makeSummary({ startTs: 40, endTs: 70, fromV: 5, toV: 7 }), + makeSummary({ + startTs: 0, + endTs: 30, + fromV: 3, + toV: 5, + labels: [label], + }), + ]) + }) + + describe('updates with origin', function () { + setupChunks([ + [ + makeUpdate({ startTs: 0, v: 1 }), + makeUpdate({ startTs: 10, v: 2 }), + makeUpdate({ + startTs: 20, + v: 3, + origin: { kind: 'history-resync' }, + }), + makeUpdate({ + startTs: 30, + v: 4, + origin: { kind: 'history-resync' }, + }), + makeUpdate({ startTs: 40, v: 5 }), + makeUpdate({ startTs: 50, v: 6 }), + ], + ]) + + expectSummaries( + 'should split the updates where the origin appears or disappears', + {}, + [ + makeSummary({ startTs: 40, endTs: 60, fromV: 5, toV: 7 }), + makeSummary({ + startTs: 20, + endTs: 40, + fromV: 3, + toV: 5, + origin: { kind: 'history-resync' }, + }), + makeSummary({ startTs: 0, endTs: 20, fromV: 1, toV: 3 }), + ] + ) + }) + + describe('updates with different origins', function () { + setupChunks([ + [ + makeUpdate({ startTs: 0, v: 1, origin: { kind: 'origin-a' } }), + makeUpdate({ startTs: 10, v: 2, origin: { kind: 'origin-a' } }), + makeUpdate({ startTs: 20, v: 3, origin: { kind: 'origin-b' } }), + makeUpdate({ startTs: 30, v: 4, origin: { kind: 'origin-b' } }), + ], + ]) + expectSummaries( + 'should split the updates when the origin kind changes', + {}, + [ + makeSummary({ + startTs: 20, + endTs: 40, + fromV: 3, + toV: 5, + origin: { kind: 'origin-b' }, + }), + makeSummary({ + startTs: 0, + endTs: 20, + fromV: 1, + toV: 3, + origin: { kind: 'origin-a' }, + }), + ] + ) + }) + + describe('history resync updates', function () { + setupChunks([ + [ + makeUpdate({ + startTs: 0, + v: 1, + origin: { kind: 'history-resync' }, + projectOps: [{ add: { pathname: 'file1.tex' } }], + pathnames: [], + }), + makeUpdate({ + startTs: 20, + v: 2, + origin: { kind: 'history-resync' }, + projectOps: [ + { add: { pathname: 'file2.tex' } }, + { add: { pathname: 'file3.tex' } }, + ], + pathnames: [], + }), + makeUpdate({ + startTs: 40, + v: 3, + origin: { kind: 'history-resync' }, + projectOps: [{ add: { pathname: 'file4.tex' } }], + pathnames: [], + }), + makeUpdate({ + startTs: 60, + v: 4, + origin: { kind: 'history-resync' }, + projectOps: [], + pathnames: ['file1.tex', 'file2.tex', 'file5.tex'], + }), + makeUpdate({ + startTs: 80, + v: 5, + origin: { kind: 'history-resync' }, + projectOps: [], + pathnames: ['file4.tex'], + }), + makeUpdate({ startTs: 100, v: 6, pathnames: ['file1.tex'] }), + ], + ]) + expectSummaries('should merge creates and edits', {}, [ + makeSummary({ + startTs: 100, + endTs: 110, + fromV: 6, + toV: 7, + pathnames: ['file1.tex'], + }), + makeSummary({ + startTs: 0, + endTs: 90, + fromV: 1, + toV: 6, + origin: { kind: 'history-resync' }, + pathnames: ['file5.tex'], + projectOps: [ + { add: { pathname: 'file4.tex' }, atV: 3 }, + { add: { pathname: 'file2.tex' }, atV: 2 }, + { add: { pathname: 'file3.tex' }, atV: 2 }, + { add: { pathname: 'file1.tex' }, atV: 1 }, + ], + }), + ]) + }) + }) + }) +}) + +/** + * Set up mocks as if the project had a number of chunks. + * + * Each parameter represents a chunk and the value of the parameter is the list + * of updates in that chunk. + */ +function setupChunks(updatesByChunk) { + beforeEach('set up chunks', function () { + let startVersion = 0 + for (let i = 0; i < updatesByChunk.length; i++) { + const updates = updatesByChunk[i] + const chunk = { chunk: { startVersion } } + + // Find the chunk by any update version + for (const update of updates) { + this.HistoryStoreManager.getChunkAtVersion + .withArgs(this.projectId, this.historyId, update.v) + .yields(null, chunk) + startVersion = update.v + } + + if (i === updatesByChunk.length - 1) { + this.HistoryStoreManager.getMostRecentChunk + .withArgs(this.projectId, this.historyId) + .yields(null, chunk) + } + + this.ChunkTranslator.convertToSummarizedUpdates + .withArgs(chunk) + .yields(null, updates) + } + }) +} + +function setupLabels(labels) { + beforeEach('set up labels', function () { + this.LabelsManager.getLabels.withArgs(this.projectId).yields(null, labels) + }) +} + +function expectSummaries(description, options, expectedSummaries) { + it(`${description}`, function (done) { + this.SummarizedUpdatesManager.getSummarizedProjectUpdates( + this.projectId, + options, + (err, summaries) => { + if (err) { + return done(err) + } + + // The order of the users array is not significant + for (const summary of summaries) { + summary.meta.users.sort() + } + for (const summary of expectedSummaries) { + summary.meta.users.sort() + } + + expect(summaries).to.deep.equal(expectedSummaries) + done() + } + ) + }) +} + +function makeUpdate(options = {}) { + const { + pathnames = ['main.tex'], + users = ['user1'], + projectOps = [], + startTs = 0, + endTs = startTs + 10, + v = 1, + origin, + } = options + const update = { + pathnames, + project_ops: projectOps, + meta: { users, start_ts: startTs, end_ts: endTs }, + v, + } + if (origin) { + update.meta.origin = origin + } + return update +} + +function makeSummary(options = {}) { + const { + pathnames = ['main.tex'], + users = ['user1'], + startTs = 0, + endTs = startTs + 10, + fromV = 1, + toV = fromV + 1, + labels = [], + projectOps = [], + origin, + } = options + const summary = { + pathnames: new Set(pathnames), + meta: { + users, + start_ts: startTs, + end_ts: endTs, + }, + fromV, + toV, + labels, + project_ops: projectOps, + } + if (origin) { + summary.meta.origin = origin + } + return summary +} diff --git a/services/project-history/test/unit/js/SyncManager/SyncManagerTests.js b/services/project-history/test/unit/js/SyncManager/SyncManagerTests.js new file mode 100644 index 0000000000..ea3b86a8b1 --- /dev/null +++ b/services/project-history/test/unit/js/SyncManager/SyncManagerTests.js @@ -0,0 +1,1073 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { ObjectId } from 'mongodb' +import tk from 'timekeeper' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/SyncManager.js' + +const timestamp = new Date() + +const resyncProjectStructureUpdate = (docs, files) => ({ + resyncProjectStructure: { docs, files }, + + meta: { + ts: timestamp, + }, +}) + +const docContentSyncUpdate = (doc, content) => ({ + path: doc.path, + doc: doc.doc, + + resyncDocContent: { + content, + }, + + meta: { + ts: timestamp, + }, +}) + +describe('SyncManager', function () { + beforeEach(async function () { + this.now = new Date() + tk.freeze(this.now) + this.projectId = new ObjectId().toString() + this.historyId = 'mock-overleaf-id' + this.syncState = { origin: { kind: 'history-resync' } } + this.db = { + projectHistorySyncState: { + findOne: sinon.stub().yields(null, this.syncState), + updateOne: sinon.stub().yields(), + }, + } + this.callback = sinon.stub() + this.extendLock = sinon.stub().yields() + this.LockManager = { + runWithLock: sinon.spy((key, runner, callback) => { + runner(this.extendLock, callback) + }), + } + this.UpdateCompressor = { + diffAsShareJsOps: sinon.stub(), + } + this.UpdateTranslator = { + isTextUpdate: sinon.stub(), + _convertPathname: sinon.stub(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + requestResync: sinon.stub().yields(), + } + this.WebApiManager.getHistoryId + .withArgs(this.projectId) + .yields(null, this.historyId) + this.ErrorRecorder = { + record: sinon.stub().yields(), + recordSyncStart: sinon.stub().yields(), + } + this.RedisManager = {} + this.SnapshotManager = { + getLatestSnapshot: sinon.stub(), + } + this.HistoryStoreManager = { + getBlobStore: sinon.stub(), + _getBlobHashFromString: sinon.stub().returns('random-hash'), + } + this.HashManager = { + _getBlobHashFromString: sinon.stub(), + } + this.Metrics = { inc: sinon.stub() } + this.Settings = { + redis: { + lock: { + key_schema: { + projectHistoryLock({ project_id: projectId }) { + return `ProjectHistoryLock:${projectId}` + }, + }, + }, + }, + } + this.SyncManager = await esmock(MODULE_PATH, { + '../../../../app/js/LockManager.js': this.LockManager, + '../../../../app/js/UpdateCompressor.js': this.UpdateCompressor, + '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator, + '../../../../app/js/mongodb.js': { ObjectId, db: this.db }, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder, + '../../../../app/js/RedisManager.js': this.RedisManager, + '../../../../app/js/SnapshotManager.js': this.SnapshotManager, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/HashManager.js': this.HashManager, + '@overleaf/metrics': this.Metrics, + '@overleaf/settings': this.Settings, + }) + }) + + afterEach(function () { + tk.reset() + }) + + describe('startResync', function () { + describe('if a sync is not in progress', function () { + beforeEach(function () { + this.db.projectHistorySyncState.findOne.yields(null, {}) + this.SyncManager.startResync(this.projectId, this.callback) + }) + + it('takes the project lock', function () { + expect(this.LockManager.runWithLock).to.have.been.calledWith( + `ProjectHistoryLock:${this.projectId}` + ) + }) + + it('gets the sync state from mongo', function () { + expect(this.db.projectHistorySyncState.findOne).to.have.been.calledWith( + { project_id: ObjectId(this.projectId) } + ) + }) + + it('requests a resync from web', function () { + expect(this.WebApiManager.requestResync).to.have.been.calledWith( + this.projectId + ) + }) + + it('sets the sync state in mongo and prevents it expiring', function () { + expect( + this.db.projectHistorySyncState.updateOne + ).to.have.been.calledWith( + { + project_id: ObjectId(this.projectId), + }, + sinon.match({ + $set: { + resyncProjectStructure: true, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + }, + $currentDate: { lastUpdated: true }, + $inc: { resyncCount: 1 }, + $unset: { expiresAt: true }, + }), + { + upsert: true, + } + ) + }) + + it('calls the callback', function () { + expect(this.callback).to.have.been.called + }) + }) + + describe('if project structure sync is in progress', function () { + beforeEach(function () { + const syncState = { resyncProjectStructure: true } + this.db.projectHistorySyncState.findOne.yields(null, syncState) + this.SyncManager.startResync(this.projectId, this.callback) + }) + + it('returns an error if already syncing', function () { + expect(this.callback).to.have.been.calledWithMatch({ + message: 'sync ongoing', + }) + }) + }) + + describe('if doc content sync in is progress', function () { + beforeEach(function () { + const syncState = { resyncDocContents: ['/foo.tex'] } + this.db.projectHistorySyncState.findOne.yields(null, syncState) + this.SyncManager.startResync(this.projectId, this.callback) + }) + + it('returns an error if already syncing', function () { + expect(this.callback).to.have.been.calledWithMatch({ + message: 'sync ongoing', + }) + }) + }) + }) + + describe('setResyncState', function () { + describe('when the sync is starting', function () { + beforeEach(function () { + this.syncState = { + toRaw() { + return { + resyncProjectStructure: true, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + } + }, + isSyncOngoing: sinon.stub().returns(true), + } + }) + + it('sets the sync state in mongo and prevents it expiring', function (done) { + // SyncState is a private class of SyncManager + // we know the interface however: + this.SyncManager.setResyncState( + this.projectId, + this.syncState, + error => { + expect(error).to.be.undefined + expect( + this.db.projectHistorySyncState.updateOne + ).to.have.been.calledWith( + { + project_id: ObjectId(this.projectId), + }, + sinon.match({ + $set: this.syncState.toRaw(), + $currentDate: { lastUpdated: true }, + $inc: { resyncCount: 1 }, + $unset: { expiresAt: true }, + }), + { + upsert: true, + } + ) + done() + } + ) + }) + }) + + describe('when the sync is ending', function () { + beforeEach(function () { + this.syncState = { + toRaw() { + return { + resyncProjectStructure: false, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + } + }, + isSyncOngoing: sinon.stub().returns(false), + } + }) + + it('sets the sync state entry in mongo to expire', function (done) { + this.SyncManager.setResyncState( + this.projectId, + this.syncState, + error => { + expect(error).to.be.undefined + expect( + this.db.projectHistorySyncState.updateOne + ).to.have.been.calledWith( + { + project_id: ObjectId(this.projectId), + }, + sinon.match({ + $set: { + resyncProjectStructure: false, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + expiresAt: new Date( + this.now.getTime() + 90 * 24 * 3600 * 1000 + ), + }, + $currentDate: { lastUpdated: true }, + }), + { + upsert: true, + } + ) + done() + } + ) + }) + }) + + describe('when the new sync state is null', function () { + it('does not update the sync state in mongo', function (done) { + // SyncState is a private class of SyncManager + // we know the interface however: + this.SyncManager.setResyncState(this.projectId, null, error => { + expect(error).to.be.undefined + expect(this.db.projectHistorySyncState.updateOne).to.not.have.been + .called + done() + }) + }) + }) + }) + + describe('skipUpdatesDuringSync', function () { + describe('if a sync is not in progress', function () { + beforeEach(function () { + this.db.projectHistorySyncState.findOne.yields(null, {}) + this.updates = ['some', 'mock', 'updates'] + this.SyncManager.skipUpdatesDuringSync( + this.projectId, + this.updates, + this.callback + ) + }) + + it('returns all updates', function () { + expect(this.callback).to.have.been.calledWith(null, this.updates) + }) + + it('should not return any newSyncState', function () { + expect(this.callback).to.have.been.calledWithExactly(null, this.updates) + }) + }) + + describe('if a sync in is progress', function () { + beforeEach(function () { + this.renameUpdate = { + pathname: 'old.tex', + newPathname: 'new.tex', + } + this.projectStructureSyncUpdate = { + resyncProjectStructure: { + docs: [{ path: 'new.tex' }], + files: [], + }, + } + this.textUpdate = { + doc: new ObjectId(), + op: [{ i: 'a', p: 4 }], + meta: { + pathname: 'new.tex', + doc_length: 4, + }, + } + this.docContentSyncUpdate = { + path: 'new.tex', + resyncDocContent: { + content: 'a', + }, + } + this.UpdateTranslator.isTextUpdate + .withArgs(this.renameUpdate) + .returns(false) + this.UpdateTranslator.isTextUpdate + .withArgs(this.projectStructureSyncUpdate) + .returns(false) + this.UpdateTranslator.isTextUpdate + .withArgs(this.docContentSyncUpdate) + .returns(false) + this.UpdateTranslator.isTextUpdate + .withArgs(this.textUpdate) + .returns(true) + + const syncState = { + resyncProjectStructure: true, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + } + this.db.projectHistorySyncState.findOne.yields(null, syncState) + }) + + it('remove updates before a project structure sync update', function () { + const updates = [ + this.renameUpdate, + this.textUpdate, + this.projectStructureSyncUpdate, + ] + this.SyncManager.skipUpdatesDuringSync( + this.projectId, + updates, + (error, filteredUpdates, syncState) => { + expect(error).to.be.null + expect(filteredUpdates).to.deep.equal([ + this.projectStructureSyncUpdate, + ]) + expect(syncState.toRaw()).to.deep.equal({ + resyncProjectStructure: false, + resyncDocContents: ['new.tex'], + origin: { kind: 'history-resync' }, + }) + } + ) + }) + + it('allow project structure updates after project structure sync update', function () { + const updates = [this.projectStructureSyncUpdate, this.renameUpdate] + this.SyncManager.skipUpdatesDuringSync( + this.projectId, + updates, + (error, filteredUpdates, syncState) => { + expect(error).to.be.null + expect(filteredUpdates).to.deep.equal([ + this.projectStructureSyncUpdate, + this.renameUpdate, + ]) + expect(syncState.toRaw()).to.deep.equal({ + resyncProjectStructure: false, + resyncDocContents: ['new.tex'], + origin: { kind: 'history-resync' }, + }) + } + ) + }) + + it('remove text updates for a doc before doc sync update', function () { + const updates = [ + this.projectStructureSyncUpdate, + this.textUpdate, + this.docContentSyncUpdate, + ] + this.SyncManager.skipUpdatesDuringSync( + this.projectId, + updates, + (error, filteredUpdates, syncState) => { + expect(error).to.be.null + expect(filteredUpdates).to.deep.equal([ + this.projectStructureSyncUpdate, + this.docContentSyncUpdate, + ]) + expect(syncState.toRaw()).to.deep.equal({ + resyncProjectStructure: false, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + }) + } + ) + }) + + it('allow text updates for a doc after doc sync update', function () { + const updates = [ + this.projectStructureSyncUpdate, + this.docContentSyncUpdate, + this.textUpdate, + ] + this.SyncManager.skipUpdatesDuringSync( + this.projectId, + updates, + (error, filteredUpdates, syncState) => { + expect(error).to.be.null + expect(filteredUpdates).to.deep.equal([ + this.projectStructureSyncUpdate, + this.docContentSyncUpdate, + this.textUpdate, + ]) + expect(syncState.toRaw()).to.deep.equal({ + resyncProjectStructure: false, + resyncDocContents: [], + origin: { kind: 'history-resync' }, + }) + } + ) + }) + }) + }) + + describe('expandSyncUpdates', function () { + beforeEach(function () { + this.persistedDoc = { + doc: { data: { hash: 'abcdef' } }, + path: 'main.tex', + content: 'asdf', + } + this.persistedFile = { + file: { data: { hash: '123456789a' } }, + path: '1.png', + } + this.fileMap = { + 'main.tex': { + isEditable: sinon.stub().returns(true), + content: this.persistedDoc.content, + }, + '1.png': { + isEditable: sinon.stub().returns(false), + data: { hash: this.persistedFile.file.data.hash }, + }, + } + this.UpdateTranslator._convertPathname + .withArgs('main.tex') + .returns('main.tex') + this.UpdateTranslator._convertPathname + .withArgs('/main.tex') + .returns('main.tex') + this.UpdateTranslator._convertPathname + .withArgs('another.tex') + .returns('another.tex') + this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png') + this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png') + this.SnapshotManager.getLatestSnapshot.yields(null, this.fileMap) + }) + + it('returns updates if no sync updates are queued', function () { + const updates = ['some', 'mock', 'updates'] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.equal(updates) + expect(this.SnapshotManager.getLatestSnapshot).to.not.have.been.called + expect(this.extendLock).to.not.have.been.called + } + ) + }) + + describe('expanding project structure sync updates', function () { + it('queues nothing for expected docs and files', function () { + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues file removes for unexpected files', function () { + const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: this.persistedFile.path, + new_pathname: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues doc removes for unexpected docs', function () { + const updates = [resyncProjectStructureUpdate([], [this.persistedFile])] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: this.persistedDoc.path, + new_pathname: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues file additions for missing files', function () { + const newFile = { + path: '2.png', + file: {}, + url: 'filestore/2.png', + } + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile, newFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: newFile.path, + file: newFile.file, + url: newFile.url, + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues blank doc additions for missing docs', function () { + const newDoc = { + path: 'another.tex', + doc: ObjectId().toString(), + } + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc, newDoc], + [this.persistedFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: newDoc.path, + doc: newDoc.doc, + docLines: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('removes and re-adds files if whether they are binary differs', function () { + const fileWichWasADoc = { + path: this.persistedDoc.path, + url: 'filestore/2.png', + _hash: 'other-hash', + } + + const updates = [ + resyncProjectStructureUpdate( + [], + [fileWichWasADoc, this.persistedFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: fileWichWasADoc.path, + new_pathname: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + { + pathname: fileWichWasADoc.path, + file: fileWichWasADoc.file, + url: fileWichWasADoc.url, + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it("does not remove and re-add files if the expected file doesn't have a hash", function () { + const fileWichWasADoc = { + path: this.persistedDoc.path, + url: 'filestore/2.png', + } + + const updates = [ + resyncProjectStructureUpdate( + [], + [fileWichWasADoc, this.persistedFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('does not remove and re-add editable files if there is a binary file with same hash', function () { + const binaryFile = { + file: Object().toString(), + // The paths in the resyncProjectStructureUpdate must have a leading slash ('/') + // The other unit tests in this file are incorrectly missing the leading slash. + // The leading slash is present in web where the paths are created with + // ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory. + path: '/' + this.persistedDoc.path, + url: 'filestore/12345', + _hash: 'abcdef', + } + this.fileMap['main.tex'].data = { hash: 'abcdef' } + + const updates = [ + resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('removes and re-adds binary files if they do not have same hash', function () { + const persistedFileWithNewContent = { + _hash: 'anotherhashvalue', + hello: 'world', + path: '1.png', + url: 'filestore-new-url', + } + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [persistedFileWithNewContent] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: persistedFileWithNewContent.path, + new_pathname: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + { + pathname: persistedFileWithNewContent.path, + file: persistedFileWithNewContent.file, + url: persistedFileWithNewContent.url, + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('preserves other updates', function () { + const update = 'mock-update' + const updates = [ + update, + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([update]) + expect(this.extendLock).to.have.been.called + } + ) + }) + }) + + describe('expanding doc contents sync updates', function () { + it('returns errors from diffAsShareJsOps', function () { + const diffError = new Error('test') + this.UpdateCompressor.diffAsShareJsOps.throws(diffError) + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.equal(diffError) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('handles an update for a file that is missing from the snapshot', function () { + const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.exist + expect(error.message).to.equal('unrecognised file: not in snapshot') + } + ) + }) + + it('queues nothing for in docs whose contents is in sync', function () { + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content), + ] + this.UpdateCompressor.diffAsShareJsOps.returns([]) + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues text updates for docs whose contents is out of sync', function () { + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, 'a'), + ] + this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }]) + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + doc: this.persistedDoc.doc, + op: [{ d: 'sdf', p: 1 }], + meta: { + pathname: this.persistedDoc.path, + doc_length: 4, + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('queues text updates for docs created by project structure sync', function () { + this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }]) + const newDoc = { + path: 'another.tex', + doc: ObjectId().toString(), + content: 'a', + } + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc, newDoc], + [this.persistedFile] + ), + docContentSyncUpdate(newDoc, newDoc.content), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + pathname: newDoc.path, + doc: newDoc.doc, + docLines: '', + meta: { + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + { + doc: newDoc.doc, + op: [{ i: 'a', p: 0 }], + meta: { + pathname: newDoc.path, + doc_length: 0, + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('skips text updates for docs when hashes match', function () { + this.fileMap['main.tex'].getHash = sinon.stub().returns('special-hash') + this.HashManager._getBlobHashFromString.returns('special-hash') + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, 'hello'), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + it('computes text updates for docs when hashes differ', function () { + this.fileMap['main.tex'].getHash = sinon.stub().returns('first-hash') + this.HashManager._getBlobHashFromString.returns('second-hash') + this.UpdateCompressor.diffAsShareJsOps.returns([ + { i: 'test diff', p: 0 }, + ]) + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, 'hello'), + ] + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + expect(error).to.be.null + expect(expandedUpdates).to.deep.equal([ + { + doc: this.persistedDoc.doc, + op: [{ i: 'test diff', p: 0 }], + meta: { + pathname: this.persistedDoc.path, + doc_length: 4, + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + } + ) + }) + + describe('for docs whose contents is out of sync', function () { + beforeEach(function (done) { + const updates = [ + resyncProjectStructureUpdate( + [this.persistedDoc], + [this.persistedFile] + ), + docContentSyncUpdate(this.persistedDoc, 'a'), + ] + const file = { getContent: sinon.stub().returns('stored content') } + this.fileMap['main.tex'].load = sinon.stub().resolves(file) + this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }]) + this.SyncManager.expandSyncUpdates( + this.projectId, + this.historyId, + updates, + this.extendLock, + (error, expandedUpdates) => { + this.error = error + this.expandedUpdates = expandedUpdates + done() + } + ) + }) + + it('loads content from the history service when needed', function () { + expect(this.error).to.be.null + expect(this.expandedUpdates).to.deep.equal([ + { + doc: this.persistedDoc.doc, + op: [{ d: 'sdf', p: 1 }], + meta: { + pathname: this.persistedDoc.path, + doc_length: 'stored content'.length, + resync: true, + ts: timestamp, + origin: { kind: 'history-resync' }, + }, + }, + ]) + expect(this.extendLock).to.have.been.called + }) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/UpdateCompressor/UpdateCompressorTests.js b/services/project-history/test/unit/js/UpdateCompressor/UpdateCompressorTests.js new file mode 100644 index 0000000000..9e4f94ff69 --- /dev/null +++ b/services/project-history/test/unit/js/UpdateCompressor/UpdateCompressorTests.js @@ -0,0 +1,942 @@ +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/UpdateCompressor.js' + +const bigstring = 'a'.repeat(2 * 1024 * 1024) +const mediumstring = 'a'.repeat(1024 * 1024) + +describe('UpdateCompressor', function () { + beforeEach(async function () { + this.UpdateCompressor = await esmock(MODULE_PATH) + this.user_id = 'user-id-1' + this.other_user_id = 'user-id-2' + this.doc_id = 'mock-doc-id' + this.ts1 = Date.now() + this.ts2 = Date.now() + 1000 + }) + + describe('convertToSingleOpUpdates', function () { + it('should split grouped updates into individual updates', function () { + expect( + this.UpdateCompressor.convertToSingleOpUpdates([ + { + op: [ + (this.op1 = { p: 0, i: 'Foo' }), + (this.op2 = { p: 6, i: 'bar' }), + ], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: [(this.op3 = { p: 10, i: 'baz' })], + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: this.op1, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: this.op2, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: this.op3, + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + }) + + it('should return no-op updates when the op list is empty', function () { + expect( + this.UpdateCompressor.convertToSingleOpUpdates([ + { + op: [], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + ]) + ).to.deep.equal([]) + }) + + it('should ignore comment ops', function () { + expect( + this.UpdateCompressor.convertToSingleOpUpdates([ + { + op: [ + (this.op1 = { p: 0, i: 'Foo' }), + (this.op2 = { p: 9, c: 'baz' }), + (this.op3 = { p: 6, i: 'bar' }), + ], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + ]) + ).to.deep.equal([ + { + op: this.op1, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: this.op3, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + ]) + }) + + it('should update doc_length when splitting after an insert', function () { + expect( + this.UpdateCompressor.convertToSingleOpUpdates([ + { + op: [ + (this.op1 = { p: 0, i: 'foo' }), + (this.op2 = { p: 6, d: 'bar' }), + ], + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 20 }, + v: 42, + }, + ]) + ).to.deep.equal([ + { + op: this.op1, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 20 }, + v: 42, + }, + { + op: this.op2, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 23 }, + v: 42, + }, + ]) + }) + + it('should update doc_length when splitting after a delete', function () { + expect( + this.UpdateCompressor.convertToSingleOpUpdates([ + { + op: [ + (this.op1 = { p: 0, d: 'foo' }), + (this.op2 = { p: 6, i: 'bar' }), + ], + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 20 }, + v: 42, + }, + ]) + ).to.deep.equal([ + { + op: this.op1, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 20 }, + v: 42, + }, + { + op: this.op2, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 17 }, + v: 42, + }, + ]) + }) + }) + + describe('concatUpdatesWithSameVersion', function () { + it('should concat updates with the same version, doc and pathname', function () { + expect( + this.UpdateCompressor.concatUpdatesWithSameVersion([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op1 = { p: 0, i: 'Foo' }), + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op2 = { p: 6, i: 'bar' }), + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op3 = { p: 10, i: 'baz' }), + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: [this.op1, this.op2], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: this.doc_id, + pathname: 'main.tex', + op: [this.op3], + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + }) + + it('should not concat updates with different doc id', function () { + expect( + this.UpdateCompressor.concatUpdatesWithSameVersion([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op1 = { p: 0, i: 'Foo' }), + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: 'other', + pathname: 'main.tex', + op: (this.op2 = { p: 6, i: 'bar' }), + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op3 = { p: 10, i: 'baz' }), + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: [this.op1], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: 'other', + pathname: 'main.tex', + op: [this.op2], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: this.doc_id, + pathname: 'main.tex', + op: [this.op3], + meta: { ts: this.ts2, user_id: this.other_user_id }, + v: 43, + }, + ]) + }) + + it('should not concat text updates with project structure ops', function () { + expect( + this.UpdateCompressor.concatUpdatesWithSameVersion([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: (this.op1 = { p: 0, i: 'Foo' }), + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: 'main.tex', + new_pathname: 'new.tex', + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + ]) + ).to.deep.equal([ + { + doc: this.doc_id, + pathname: 'main.tex', + op: [this.op1], + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: 'main.tex', + new_pathname: 'new.tex', + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + ]) + }) + }) + + describe('compress', function () { + describe('insert - insert', function () { + it('should append one insert to the other', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foobar' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should insert one insert inside the other', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 5, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'fobaro' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append separated inserts', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append inserts that are too big (second op)', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: bigstring }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: bigstring }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append inserts that are too big (first op)', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: bigstring }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3 + bigstring.length, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: bigstring }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3 + bigstring.length, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append inserts that are too big (first and second op)', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: mediumstring }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3 + mediumstring.length, i: mediumstring }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: mediumstring }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3 + mediumstring.length, i: mediumstring }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append inserts that are a long time appart', function () { + this.ts3 = this.ts1 + 120000 // 2 minutes + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts3, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts3, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append inserts separated by project structure ops', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: '/old.tex', + new_pathname: '/new.tex', + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 44, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: '/old.tex', + new_pathname: '/new.tex', + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 44, + }, + ]) + }) + + it('should not append ops from different doc ids', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + doc: 'doc-one', + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: 'doc-two', + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + doc: 'doc-one', + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + doc: 'doc-two', + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append ops from different doc pathnames', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + pathname: 'doc-one', + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: 'doc-two', + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + pathname: 'doc-one', + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + pathname: 'doc-two', + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + }) + + describe('delete - delete', function () { + it('should append one delete to the other', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, d: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, d: 'foobar' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should insert one delete inside the other', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, d: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 1, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 1, d: 'bafoor' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not append separated deletes', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, d: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, d: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + }) + + describe('insert - delete', function () { + it('should undo a previous insert', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 5, d: 'o' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'fo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should remove part of an insert from the middle', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'fobaro' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 5, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should cancel out two opposite updates', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3, d: 'foo' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([]) + }) + + it('should not combine separated updates', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 9, d: 'bar' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + + it('should not combine updates with overlap beyond the end', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foobar' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, d: 'bardle' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foobar' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, d: 'bardle' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + }) + }) + + describe('delete - insert', function () { + it('should do a diff of the content', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, d: 'one two three four five six seven eight' }, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 100 }, + v: 42, + }, + { + op: { p: 3, i: 'one 2 three four five six seven eight' }, + meta: { ts: this.ts2, user_id: this.user_id, doc_length: 100 }, + v: 43, + }, + ]) + ).to.deep.equal([ + { + op: { p: 7, d: 'two' }, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 100 }, + v: 43, + }, + { + op: { p: 7, i: '2' }, + meta: { ts: this.ts1, user_id: this.user_id, doc_length: 97 }, + v: 43, + }, + ]) + }) + + it('should return a no-op if the delete and insert are the same', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, d: 'one two three four five six seven eight' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 3, i: 'one two three four five six seven eight' }, + meta: { ts: this.ts2, user_id: this.user_id }, + v: 43, + }, + ]) + ).to.deep.equal([]) + }) + }) + + describe('a long chain of ops', function () { + it('should always split after 60 seconds', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 42, + }, + { + op: { p: 6, i: 'bar' }, + meta: { ts: this.ts1 + 20000, user_id: this.user_id }, + v: 43, + }, + { + op: { p: 9, i: 'baz' }, + meta: { ts: this.ts1 + 40000, user_id: this.user_id }, + v: 44, + }, + { + op: { p: 12, i: 'qux' }, + meta: { ts: this.ts1 + 80000, user_id: this.user_id }, + v: 45, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foobarbaz' }, + meta: { ts: this.ts1, user_id: this.user_id }, + v: 44, + }, + { + op: { p: 12, i: 'qux' }, + meta: { ts: this.ts1 + 80000, user_id: this.user_id }, + v: 45, + }, + ]) + }) + }) + + describe('external updates', function () { + it('should be split from editor updates and from other sources', function () { + expect( + this.UpdateCompressor.compressUpdates([ + { + op: { p: 3, i: 'foo' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + source: 'some-editor-id', + }, + v: 42, + }, + { + op: { p: 6, i: 'bar' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + source: 'some-other-editor-id', + }, + v: 43, + }, + { + op: { p: 9, i: 'baz' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + type: 'external', + source: 'dropbox', + }, + v: 44, + }, + { + op: { p: 12, i: 'qux' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + type: 'external', + source: 'dropbox', + }, + v: 45, + }, + { + op: { p: 15, i: 'quux' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + type: 'external', + source: 'upload', + }, + v: 46, + }, + ]) + ).to.deep.equal([ + { + op: { p: 3, i: 'foobar' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + source: 'some-editor-id', + }, + v: 43, + }, + { + op: { p: 9, i: 'bazqux' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + type: 'external', + source: 'dropbox', + }, + v: 45, + }, + { + op: { p: 15, i: 'quux' }, + meta: { + ts: this.ts1, + user_id: this.user_id, + type: 'external', + source: 'upload', + }, + v: 46, + }, + ]) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/UpdateTranslator/UpdateTranslatorTests.js b/services/project-history/test/unit/js/UpdateTranslator/UpdateTranslatorTests.js new file mode 100644 index 0000000000..6ea3c47c6f --- /dev/null +++ b/services/project-history/test/unit/js/UpdateTranslator/UpdateTranslatorTests.js @@ -0,0 +1,838 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' +import Core from 'overleaf-editor-core' + +const MODULE_PATH = '../../../../app/js/UpdateTranslator.js' + +describe('UpdateTranslator', function () { + beforeEach(async function () { + this.UpdateTranslator = await esmock(MODULE_PATH, { + 'overleaf-editor-core': Core, + }) + this.callback = sinon.stub() + + this.project_id = '59bfd450e3028c4d40a1e9aa' + this.doc_id = '59bfd450e3028c4d40a1e9ab' + this.file_id = '59bfd450e3028c4d40a1easd' + this.user_id = '59bb9051abf6e8682a269b64' + this.version = 0 + this.timestamp = new Date().toJSON() + this.mockBlobHash = '12345abc12345abc12345abc12345abc12345abc' + }) + + describe('convertToChanges', function () { + it('can translate doc additions', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + pathname: '/main.tex', + docLines: 'a\nb', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate file additions', function (done) { + const updates = [ + { + update: { + file: this.file_id, + pathname: '/test.png', + url: 'filestore.example.com/test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'test.png', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate doc renames', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + pathname: '/main.tex', + new_pathname: '/new_main.tex', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + newPathname: 'new_main.tex', + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate file renames', function (done) { + const updates = [ + { + update: { + file: this.file_id, + pathname: '/test.png', + new_pathname: '/new_test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'test.png', + newPathname: 'new_test.png', + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate multiple updates with the correct versions', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + pathname: '/main.tex', + docLines: 'a\nb', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + blobHash: this.mockBlobHash, + }, + { + update: { + file: this.file_id, + pathname: '/test.png', + url: 'filestore.example.com/test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + { + authors: [], + operations: [ + { + pathname: 'test.png', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('returns an error if the update has an unknown format', function (done) { + const updates = [ + { + update: { + foo: 'bar', + }, + }, + ] + const assertion = (error, changes) => { + expect(error) + .to.exist.and.be.instanceof(Error) + .and.have.property('message', 'update with unknown format') + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('replaces backslashes with underscores in pathnames', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + pathname: '/\\main\\foo.tex', + new_pathname: '/\\new_main\\foo\\bar.tex', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: '_main_foo.tex', + newPathname: '_new_main_foo_bar.tex', + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('replaces leading asterisks with __ASTERISK__ in pathnames', function (done) { + const updates = [ + { + update: { + file: this.file_id, + pathname: '/test*test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + url: 'filestore.example.com/test*test.png', + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'test__ASTERISK__test.png', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('replaces a leading space for top-level files with __SPACE__', function (done) { + const updates = [ + { + update: { + file: this.file_id, + pathname: '/ test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + url: 'filestore.example.com/test.png', + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: '__SPACE__test.png', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('replaces leading spaces of files in subfolders with __SPACE__', function (done) { + const updates = [ + { + update: { + file: this.file_id, + pathname: '/folder/ test.png', + meta: { + user_id: this.user_id, + ts: this.timestamp, + }, + url: 'filestore.example.com/folder/test.png', + }, + blobHash: this.mockBlobHash, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'folder/__SPACE__test.png', + file: { + hash: this.mockBlobHash, + }, + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + describe('text updates', function () { + it('can translate insertions', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [ + { p: 3, i: 'foo' }, + { p: 15, i: 'bar' }, + ], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + source: 'some-editor-id', + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [3, 'foo', 9, 'bar', 8], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate deletions', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [ + { p: 3, d: 'lo' }, + { p: 10, d: 'bar' }, + ], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [3, -2, 7, -3, 5], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can translate insertions at the start and end (with zero retained)', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [ + { p: 0, i: 'foo' }, + { p: 23, i: 'bar' }, + { p: 0, d: 'foo' }, + { p: 20, d: 'bar' }, + ], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [20], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can handle operations in non-linear offset order', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [ + { p: 15, i: 'foo' }, + { p: 3, i: 'bar' }, + ], + v: this.version, + meta: { + user_id: this.user_id, + ts: this.timestamp, + pathname: '/main.tex', + doc_length: 20, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [3, 'bar', 12, 'foo', 5], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('can ignore comment ops', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [ + { p: 0, i: 'foo' }, + { p: 5, c: 'bar' }, + { p: 10, i: 'baz' }, + ], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: ['foo', 7, 'baz', 13], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('handles insertions after the end of the document', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [{ p: 3, i: '\\' }], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 2, + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [2, '\\'], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('translates external source metadata into an origin', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [{ p: 3, i: 'foo' }], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + type: 'external', + source: 'dropbox', + }, + }, + }, + ] + const assertion = (error, changes) => { + changes = changes.map(change => change.toRaw()) + expect(error).to.be.null + expect(changes).to.deep.equal([ + { + authors: [], + operations: [ + { + pathname: 'main.tex', + textOperation: [3, 'foo', 17], + }, + ], + v2Authors: [this.user_id], + timestamp: this.timestamp, + v2DocVersions: { + '59bfd450e3028c4d40a1e9ab': { + pathname: 'main.tex', + v: 0, + }, + }, + origin: { kind: 'dropbox' }, + }, + ]) + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + + it('errors on unexpected ops', function (done) { + const updates = [ + { + update: { + doc: this.doc_id, + op: [{ p: 5, z: 'bar' }], + v: this.version, + meta: { + user_id: this.user_id, + ts: new Date(this.timestamp).getTime(), + pathname: '/main.tex', + doc_length: 20, + }, + }, + }, + ] + const assertion = (error, changes) => { + expect(error.message).to.equal('unexpected op type') + done() + } + + this.UpdateTranslator.convertToChanges( + this.project_id, + updates, + assertion + ) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js b/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js new file mode 100644 index 0000000000..3735cdd6cb --- /dev/null +++ b/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js @@ -0,0 +1,496 @@ +/* eslint-disable + camelcase, + mocha/no-nested-tests, + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' +import * as Errors from '../../../../app/js/Errors.js' + +const MODULE_PATH = '../../../../app/js/UpdatesProcessor.js' + +describe('UpdatesProcessor', function () { + before(async function () { + this.extendLock = sinon.stub() + this.BlobManager = { + createBlobForUpdates: sinon.stub(), + } + this.HistoryStoreManager = { + getMostRecentVersion: sinon.stub(), + sendChanges: sinon.stub().yields(), + } + this.LockManager = { + runWithLock: sinon.spy((key, runner, callback) => + runner(this.extendLock, callback) + ), + } + this.RedisManager = {} + this.UpdateCompressor = { + compressRawUpdates: sinon.stub(), + } + this.UpdateTranslator = { + convertToChanges: sinon.stub(), + isProjectStructureUpdate: sinon.stub(), + isTextUpdate: sinon.stub(), + } + this.WebApiManager = { + getHistoryId: sinon.stub(), + } + this.SyncManager = { + expandSyncUpdates: sinon.stub(), + setResyncState: sinon.stub().yields(), + skipUpdatesDuringSync: sinon.stub(), + } + this.ErrorRecorder = { + getLastFailure: sinon.stub(), + record: sinon.stub().yields(), + } + this.Profiler = { + Profiler: sinon.stub(), + } + this.Metrics = { + gauge: sinon.stub(), + inc: sinon.stub(), + } + this.Settings = { + redis: { + lock: { + key_schema: { + projectHistoryLock({ project_id }) { + return `ProjectHistoryLock:${project_id}` + }, + }, + }, + }, + } + this.UpdatesProcessor = await esmock(MODULE_PATH, { + '../../../../app/js/BlobManager.js': this.BlobManager, + '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager, + '../../../../app/js/LockManager.js': this.LockManager, + '../../../../app/js/RedisManager.js': this.RedisManager, + '../../../../app/js/UpdateCompressor.js': this.UpdateCompressor, + '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator, + '../../../../app/js/WebApiManager.js': this.WebApiManager, + '../../../../app/js/SyncManager.js': this.SyncManager, + '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder, + '../../../../app/js/Profiler.js': this.Profiler, + '../../../../app/js/Errors.js': Errors, + '@overleaf/metrics': this.Metrics, + '@overleaf/settings': this.Settings, + }) + this.doc_id = 'doc-id-123' + this.project_id = 'project-id-123' + this.ol_project_id = 'ol-project-id-234' + this.callback = sinon.stub() + return (this.temporary = 'temp-mock') + }) + + describe('processUpdatesForProject', function () { + beforeEach(function () { + this.error = new Error('error') + this.queueSize = 445 + this.UpdatesProcessor._mocks._countAndProcessUpdates = sinon + .stub() + .callsArgWith(3, this.error, this.queueSize) + }) + + describe('when there is no existing error', function () { + beforeEach(function (done) { + this.ErrorRecorder.getLastFailure.yields() + return this.UpdatesProcessor.processUpdatesForProject( + this.project_id, + done + ) + }) + + it('processes updates', function () { + return this.UpdatesProcessor._mocks._countAndProcessUpdates + .calledWith(this.project_id) + .should.equal(true) + }) + + return it('records errors', function () { + return this.ErrorRecorder.record + .calledWith(this.project_id, this.queueSize, this.error) + .should.equal(true) + }) + }) + }) + + describe('_getHistoryId', function () { + describe('projectHistoryId is not present', function () { + beforeEach(function () { + this.updates = [ + { p: 0, i: 'a' }, + { p: 1, i: 's' }, + ] + this.WebApiManager.getHistoryId.yields(null) + }) + + return it('returns null', function (done) { + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + (error, projectHistoryId) => { + expect(error).to.be.null + expect(projectHistoryId).to.be.null + return done() + } + ) + }) + }) + + describe('projectHistoryId is not present in updates', function () { + beforeEach(function () { + return (this.updates = [ + { p: 0, i: 'a' }, + { p: 1, i: 's' }, + ]) + }) + + it('returns the id from web', function (done) { + this.projectHistoryId = '1234' + this.WebApiManager.getHistoryId.yields(null, this.projectHistoryId) + + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + (error, projectHistoryId) => { + expect(error).to.be.null + expect(projectHistoryId).equal(this.projectHistoryId) + return done() + } + ) + }) + + return it('returns errors from web', function (done) { + this.error = new Error('oh no!') + this.WebApiManager.getHistoryId.yields(this.error) + + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + error => { + expect(error).to.equal(this.error) + return done() + } + ) + }) + }) + + return describe('projectHistoryId is present in some updates', function () { + beforeEach(function () { + this.projectHistoryId = '1234' + return (this.updates = [ + { p: 0, i: 'a' }, + { p: 1, i: 's', projectHistoryId: this.projectHistoryId }, + { p: 2, i: 'd', projectHistoryId: this.projectHistoryId }, + ]) + }) + + it('returns an error if the id is inconsistent between updates', function (done) { + this.updates[1].projectHistoryId = 2345 + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + error => { + expect(error.message).to.equal( + 'inconsistent project history id between updates' + ) + return done() + } + ) + }) + + it('returns an error if the id is inconsistent between updates and web', function (done) { + this.WebApiManager.getHistoryId.yields(null, 2345) + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + error => { + expect(error.message).to.equal( + 'inconsistent project history id between updates and web' + ) + return done() + } + ) + }) + + it('returns the id if it is consistent between updates and web', function (done) { + this.WebApiManager.getHistoryId.yields(null, this.projectHistoryId) + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + (error, projectHistoryId) => { + expect(error).to.be.null + expect(projectHistoryId).equal(this.projectHistoryId) + return done() + } + ) + }) + + return it('returns the id if it is consistent between updates but unavaiable in web', function (done) { + this.WebApiManager.getHistoryId.yields(new Error('oh no!')) + return this.UpdatesProcessor._getHistoryId( + this.project_id, + this.updates, + (error, projectHistoryId) => { + expect(error).to.be.null + expect(projectHistoryId).equal(this.projectHistoryId) + return done() + } + ) + }) + }) + }) + + describe('_processUpdates', function () { + return beforeEach(function () { + this.mostRecentVersionInfo = { version: 1 } + this.rawUpdates = ['raw updates'] + this.expandedUpdates = ['expanded updates'] + this.filteredUpdates = ['filtered updates'] + this.compressedUpdates = ['compressed updates'] + this.updatesWithBlobs = ['updates with blob'] + this.changes = ['changes'] + this.newSyncState = { resyncProjectStructure: false } + + this.extendLock = sinon.stub().yields() + + this.HistoryStoreManager.getMostRecentVersion.yields( + null, + this.mostRecentVersionInfo + ) + this.SyncManager.skipUpdatesDuringSync.yields( + null, + this.filteredUpdates, + this.newSyncState + ) + this.SyncManager.expandSyncUpdates.yields(null, this.expandedUpdates) + this.UpdateCompressor.compressRawUpdates.returns(this.compressedUpdates) + this.BlobManager.createBlobForUpdates.yields(null, this.updatesWithBlobs) + this.UpdateTranslator.convertToChanges.yields(null, this.changes) + + this.UpdatesProcessor._processUpdates( + this.project_id, + this.rawUpdates, + this.extendLock, + done + ) + + it('should get the latest version id', function () { + return this.HistoryStoreManager.getMostRecentVersion + .calledWith(this.project_id, this.ol_project_id) + .should.equal(true) + }) + + it('should skip updates when resyncing', function () { + return this.SyncManager.skipUpdatesDuringSync + .calledWith(this.project_id, this.rawUpdates) + .should.equal(true) + }) + + it('should expand sync updates', function () { + return this.SyncManager.expandSyncUpdates + .calledWith( + this.project_id, + this.ol_project_id, + this.filteredUpdates, + this.extendLock + ) + .should.equal(true) + }) + + it('should compress updates', function () { + return this.UpdateCompressor.compressRawUpdates + .calledWith(this.expandedUpdates) + .should.equal(true) + }) + + it('should not create any blobs', function () { + return this.BlobManager.createBlobForUpdates + .calledWith(this.project_id, this.compressedUpdates) + .called.should.equal(false) + }) + + it('should convert the updates into a change requests', function () { + return this.UpdateTranslator.convertToChanges + .calledWith( + this.project_id, + this.updatesWithBlobs, + this.mostRecentVersionInfo.version + ) + .should.equal(true) + }) + + it('should send the change request to the history store', function () { + return this.HistoryStoreManager.sendChanges + .calledWith(this.project_id, this.ol_project_id, this.changes) + .should.equal(true) + }) + + it('should set the sync state', function () { + return this.SyncManager.setResyncState + .calledWith(this.project_id, this.newSyncState) + .should.equal(true) + }) + + return it('should call the callback with no error', function () { + return this.callback.called.should.equal(true) + }) + }) + }) + + return describe('_skipAlreadyAppliedUpdates', function () { + before(function () { + this.UpdateTranslator.isProjectStructureUpdate.callsFake( + update => update.version != null + ) + this.UpdateTranslator.isTextUpdate.callsFake(update => update.v != null) + }) + + describe('with all doc ops in order', function () { + before(function () { + this.updates = [ + { doc: 'id', v: 1 }, + { doc: 'id', v: 2 }, + { doc: 'id', v: 3 }, + { doc: 'id', v: 4 }, + ] + return (this.updatesToApply = + this.UpdatesProcessor._skipAlreadyAppliedUpdates( + this.project_id, + this.updates, + { docs: {} } + )) + }) + + return it('should return the original updates', function () { + return expect(this.updatesToApply).to.eql(this.updates) + }) + }) + + describe('with all project ops in order', function () { + before(function () { + this.updates = [ + { version: 1 }, + { version: 2 }, + { version: 3 }, + { version: 4 }, + ] + return (this.updatesToApply = + this.UpdatesProcessor._skipAlreadyAppliedUpdates( + this.project_id, + this.updates, + { docs: {} } + )) + }) + + return it('should return the original updates', function () { + return expect(this.updatesToApply).to.eql(this.updates) + }) + }) + + describe('with all multiple doc and ops in order', function () { + before(function () { + this.updates = [ + { doc: 'id1', v: 1 }, + { doc: 'id1', v: 2 }, + { doc: 'id1', v: 3 }, + { doc: 'id1', v: 4 }, + { doc: 'id2', v: 1 }, + { doc: 'id2', v: 2 }, + { doc: 'id2', v: 3 }, + { doc: 'id2', v: 4 }, + { version: 1 }, + { version: 2 }, + { version: 3 }, + { version: 4 }, + ] + return (this.updatesToApply = + this.UpdatesProcessor._skipAlreadyAppliedUpdates( + this.project_id, + this.updates, + { docs: {} } + )) + }) + + return it('should return the original updates', function () { + return expect(this.updatesToApply).to.eql(this.updates) + }) + }) + + describe('with doc ops out of order', function () { + before(function () { + this.updates = [ + { doc: 'id', v: 1 }, + { doc: 'id', v: 2 }, + { doc: 'id', v: 4 }, + { doc: 'id', v: 3 }, + ] + this.skipFn = sinon.spy( + this.UpdatesProcessor._mocks, + '_skipAlreadyAppliedUpdates' + ) + try { + return (this.updatesToApply = + this.UpdatesProcessor._skipAlreadyAppliedUpdates( + this.project_id, + this.updates, + { docs: {} } + )) + } catch (error) {} + }) + + after(function () { + return this.skipFn.restore() + }) + + return it('should throw an exception', function () { + return this.skipFn.threw('OpsOutOfOrderError').should.equal(true) + }) + }) + + return describe('with project ops out of order', function () { + before(function () { + this.updates = [ + { version: 1 }, + { version: 2 }, + { version: 4 }, + { version: 3 }, + ] + this.skipFn = sinon.spy( + this.UpdatesProcessor._mocks, + '_skipAlreadyAppliedUpdates' + ) + try { + return (this.updatesToApply = + this.UpdatesProcessor._skipAlreadyAppliedUpdates( + this.project_id, + this.updates, + { docs: {} } + )) + } catch (error) {} + }) + + after(function () { + return this.skipFn.restore() + }) + + return it('should throw an exception', function () { + return this.skipFn.threw('OpsOutOfOrderError').should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/Versions/VersionTest.js b/services/project-history/test/unit/js/Versions/VersionTest.js new file mode 100644 index 0000000000..8f6f0e1c4d --- /dev/null +++ b/services/project-history/test/unit/js/Versions/VersionTest.js @@ -0,0 +1,170 @@ +/* eslint-disable + no-return-assign, + no-undef, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/Versions.js' + +describe('Versions', function () { + beforeEach(async function () { + return (this.Versions = await esmock(MODULE_PATH)) + }) + + describe('compare', function () { + describe('for greater major version', function () { + return it('should return +1', function () { + return this.Versions.compare('2.1', '1.1').should.equal(+1) + }) + }) + + describe('for lesser major version', function () { + return it('should return -1', function () { + return this.Versions.compare('1.1', '2.1').should.equal(-1) + }) + }) + + describe('for equal major versions with no minor version', function () { + return it('should return 0', function () { + return this.Versions.compare('2', '2').should.equal(0) + }) + }) + + describe('for equal major versions with greater minor version', function () { + return it('should return +1', function () { + return this.Versions.compare('2.3', '2.1').should.equal(+1) + }) + }) + + describe('for equal major versions with lesser minor version', function () { + return it('should return -1', function () { + return this.Versions.compare('2.1', '2.3').should.equal(-1) + }) + }) + + describe('for equal major versions with greater minor version (non lexical)', function () { + return it('should return +1', function () { + return this.Versions.compare('2.10', '2.9').should.equal(+1) + }) + }) + + describe('for equal major versions with lesser minor version (non lexical)', function () { + return it('should return +1', function () { + return this.Versions.compare('2.9', '2.10').should.equal(-1) + }) + }) + + describe('for a single major version vs a major+minor version', function () { + return it('should return +1', function () { + return this.Versions.compare('2.1', '1').should.equal(+1) + }) + }) + + describe('for a major+minor version vs a single major version', function () { + return it('should return -1', function () { + return this.Versions.compare('1', '2.1').should.equal(-1) + }) + }) + + describe('for equal major versions with greater minor version vs zero', function () { + return it('should return +1', function () { + return this.Versions.compare('2.3', '2.0').should.equal(+1) + }) + }) + + return describe('for equal major versions with lesser minor version of zero', function () { + return it('should return -1', function () { + return this.Versions.compare('2.0', '2.3').should.equal(-1) + }) + }) + }) + + describe('gt', function () { + describe('for greater major version', function () { + return it('should return true', function () { + return this.Versions.gt('2.1', '1.1').should.equal(true) + }) + }) + + describe('for lesser major version', function () { + return it('should return false', function () { + return this.Versions.gt('1.1', '2.1').should.equal(false) + }) + }) + + return describe('for equal major versions with no minor version', function () { + return it('should return false', function () { + return this.Versions.gt('2', '2').should.equal(false) + }) + }) + }) + + describe('gte', function () { + describe('for greater major version', function () { + return it('should return true', function () { + return this.Versions.gte('2.1', '1.1').should.equal(true) + }) + }) + + describe('for lesser major version', function () { + return it('should return false', function () { + return this.Versions.gte('1.1', '2.1').should.equal(false) + }) + }) + + return describe('for equal major versions with no minor version', function () { + return it('should return true', function () { + return this.Versions.gte('2', '2').should.equal(true) + }) + }) + }) + + describe('lt', function () { + describe('for greater major version', function () { + return it('should return false', function () { + return this.Versions.lt('2.1', '1.1').should.equal(false) + }) + }) + + describe('for lesser major version', function () { + return it('should return true', function () { + return this.Versions.lt('1.1', '2.1').should.equal(true) + }) + }) + + return describe('for equal major versions with no minor version', function () { + return it('should return false', function () { + return this.Versions.lt('2', '2').should.equal(false) + }) + }) + }) + + return describe('lte', function () { + describe('for greater major version', function () { + return it('should return false', function () { + return this.Versions.lte('2.1', '1.1').should.equal(false) + }) + }) + + describe('for lesser major version', function () { + return it('should return true', function () { + return this.Versions.lte('1.1', '2.1').should.equal(true) + }) + }) + + return describe('for equal major versions with no minor version', function () { + return it('should return true', function () { + return this.Versions.lte('2', '2').should.equal(true) + }) + }) + }) +}) diff --git a/services/project-history/test/unit/js/WebApiManager/WebApiManagerTests.js b/services/project-history/test/unit/js/WebApiManager/WebApiManagerTests.js new file mode 100644 index 0000000000..01ae803e0b --- /dev/null +++ b/services/project-history/test/unit/js/WebApiManager/WebApiManagerTests.js @@ -0,0 +1,163 @@ +import async from 'async' +import sinon from 'sinon' +import { expect } from 'chai' +import { strict as esmock } from 'esmock' + +const MODULE_PATH = '../../../../app/js/WebApiManager.js' + +describe('WebApiManager', function () { + beforeEach(async function () { + this.request = sinon.stub() + this.settings = { + apis: { + web: { + url: 'http://example.com', + user: 'sharelatex', + pass: 'password', + }, + }, + } + this.callback = sinon.stub() + this.userId = 'mock-user-id' + this.projectId = 'mock-project-id' + this.project = { features: 'mock-features' } + this.olProjectId = 12345 + this.Metrics = { inc: sinon.stub() } + this.RedisManager = { + getCachedHistoryId: sinon.stub(), + setCachedHistoryId: sinon.stub().yields(), + } + this.WebApiManager = await esmock(MODULE_PATH, { + requestretry: this.request, + '@overleaf/settings': this.settings, + '@overleaf/metrics': this.Metrics, + '../../../../app/js/RedisManager.js': this.RedisManager, + }) + }) + + describe('getHistoryId', function () { + describe('when there is no cached value and the web request is successful', function () { + beforeEach(function () { + this.RedisManager.getCachedHistoryId + .withArgs(this.projectId) // first call, no cached value returned + .onCall(0) + .yields() + this.RedisManager.getCachedHistoryId + .withArgs(this.projectId) // subsequent calls, return cached value + .yields(null, this.olProjectId) + this.RedisManager.getCachedHistoryId + .withArgs('mock-project-id-2') // no cached value for other project + .yields() + this.request.yields( + null, + { statusCode: 200 }, + { overleaf: { history: { id: this.olProjectId } } } + ) + }) + + it('should only request project details once per project', function (done) { + async.times( + 5, + (n, cb) => { + this.WebApiManager.getHistoryId(this.projectId, cb) + }, + () => { + this.request.callCount.should.equal(1) + + this.WebApiManager.getHistoryId('mock-project-id-2', () => { + this.request.callCount.should.equal(2) + done() + }) + } + ) + }) + + it('should cache the history id', function (done) { + this.WebApiManager.getHistoryId( + this.projectId, + (error, olProjectId) => { + if (error) return done(error) + this.RedisManager.setCachedHistoryId + .calledWith(this.projectId, olProjectId) + .should.equal(true) + done() + } + ) + }) + + it('should call the callback with the project', function (done) { + this.WebApiManager.getHistoryId( + this.projectId, + (error, olProjectId) => { + expect(error).to.be.null + expect( + this.request.calledWithMatch({ + method: 'GET', + url: `${this.settings.apis.web.url}/project/${this.projectId}/details`, + json: true, + auth: { + user: this.settings.apis.web.user, + pass: this.settings.apis.web.pass, + sendImmediately: true, + }, + }) + ).to.be.true + expect(olProjectId).to.equal(this.olProjectId) + done() + } + ) + }) + }) + + describe('when the web API returns an error', function () { + beforeEach(function () { + this.error = new Error('something went wrong') + this.request.yields(this.error) + this.RedisManager.getCachedHistoryId.yields() + this.WebApiManager.getHistoryId(this.projectId, this.callback) + }) + + it('should return an error to the callback', function () { + this.callback.calledWith(this.error).should.equal(true) + }) + }) + + describe('when web returns a 404', function () { + beforeEach(function () { + this.request.callsArgWith(1, null, { statusCode: 404 }, '') + this.RedisManager.getCachedHistoryId.yields() + this.WebApiManager.getHistoryId(this.projectId, this.callback) + }) + + it('should return the callback with an error', function () { + this.callback + .calledWith(sinon.match.has('message', 'got a 404 from web api')) + .should.equal(true) + }) + }) + + describe('when web returns a failure error code', function () { + beforeEach(function () { + this.RedisManager.getCachedHistoryId.yields() + this.request.callsArgWith( + 1, + null, + { statusCode: 500, attempts: 42 }, + '' + ) + this.WebApiManager.getHistoryId(this.projectId, this.callback) + }) + + it('should return the callback with an error', function () { + this.callback + .calledWith( + sinon.match.has( + 'message', + 'web returned a non-success status code: 500 (attempts: 42)' + ) + ) + .should.equal(true) + }) + }) + }) +})