const { ReadPreference, ObjectId } = require('mongodb') const { db } = require('../../app/src/infrastructure/mongodb') const Settings = require('@overleaf/settings') const ProjectHistoryHandler = require('../../app/src/Features/Project/ProjectHistoryHandler') const HistoryManager = require('../../app/src/Features/History/HistoryManager') const ProjectHistoryController = require('../../modules/admin-panel/app/src/ProjectHistoryController') const ProjectEntityHandler = require('../../app/src/Features/Project/ProjectEntityHandler') const ProjectEntityUpdateHandler = require('../../app/src/Features/Project/ProjectEntityUpdateHandler') // Timestamp of when 'Enable history for SL in background' release const ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = '5a8d8a370000000000000000' const OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = new ObjectId( ID_WHEN_FULL_PROJECT_HISTORY_ENABLED ) const DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED = OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED.getTimestamp() async function determineProjectHistoryType(project) { if (project.overleaf && project.overleaf.history) { if (project.overleaf.history.upgradeFailed) { return 'UpgradeFailed' } if (project.overleaf.history.conversionFailed) { return 'ConversionFailed' } } if ( project.overleaf && project.overleaf.history && project.overleaf.history.id ) { if (project.overleaf.history.display) { // v2: full project history, do nothing return 'V2' } else { if (projectCreatedAfterFullProjectHistoryEnabled(project)) { // IF project initialised after full project history enabled for all projects // THEN project history should contain all information we need, without intervention return 'V1WithoutConversion' } else { // ELSE SL history may predate full project history // THEN delete full project history and convert their SL history to full project history // -- // TODO: how to verify this, can get rough start date of SL history, but not full project history const preserveHistory = await shouldPreserveHistory(project) const anyDocHistory = await anyDocHistoryExists(project) const anyDocHistoryIndex = await anyDocHistoryIndexExists(project) if (preserveHistory) { if (anyDocHistory || anyDocHistoryIndex) { // if SL history exists that we need to preserve, then we must convert return 'V1WithConversion' } else { // otherwise just upgrade without conversion return 'V1WithoutConversion' } } else { // if preserveHistory false, then max 7 days of SL history // but v1 already record to both histories, so safe to upgrade return 'V1WithoutConversion' } } } } else { const preserveHistory = await shouldPreserveHistory(project) const anyDocHistory = await anyDocHistoryExists(project) const anyDocHistoryIndex = await anyDocHistoryIndexExists(project) if (anyDocHistory || anyDocHistoryIndex) { // IF there is SL history -> if (preserveHistory) { // that needs to be preserved: // THEN initialise full project history and convert SL history to full project history return 'NoneWithConversion' } else { return 'NoneWithTemporaryHistory' } } else { // ELSE there is not any SL history -> // THEN initialise full project history and sync with current content return 'NoneWithoutConversion' } } } async function upgradeProject(project) { const historyType = await determineProjectHistoryType(project) if (historyType === 'V2') { return { historyType, upgraded: true } } const upgradeFn = getUpgradeFunctionForType(historyType) if (!upgradeFn) { return { error: 'unsupported history type' } } const result = await upgradeFn(project) result.historyType = historyType return result } // Do upgrades/conversion: function getUpgradeFunctionForType(historyType) { return UpgradeFunctionMapping[historyType] } const UpgradeFunctionMapping = { NoneWithoutConversion: doUpgradeForNoneWithoutConversion, UpgradeFailed: doUpgradeForNoneWithoutConversion, ConversionFailed: doUpgradeForNoneWithConversion, V1WithoutConversion: doUpgradeForV1WithoutConversion, V1WithConversion: doUpgradeForV1WithConversion, NoneWithConversion: doUpgradeForNoneWithConversion, NoneWithTemporaryHistory: doUpgradeForNoneWithConversion, } async function doUpgradeForV1WithoutConversion(project) { await db.projects.updateOne( { _id: project._id }, { $set: { 'overleaf.history.display': true, 'overleaf.history.upgradedAt': new Date(), 'overleaf.history.upgradeReason': `v1-without-sl-history`, }, } ) return { upgraded: true } } async function doUpgradeForV1WithConversion(project) { const result = {} const projectId = project._id // migrateProjectHistory expects project id as a string const projectIdString = project._id.toString() try { // We treat these essentially as None projects, the V1 history is irrelevant, // so we will delete it, and do a conversion as if we're a None project await ProjectHistoryController.deleteProjectHistory(projectIdString) await ProjectHistoryController.migrateProjectHistory(projectIdString) } catch (err) { // if migrateProjectHistory fails, it cleans up by deleting // the history and unsetting the history id // therefore a failed project will still look like a 'None with conversion' project result.error = err await db.projects.updateOne( { _id: projectId }, { $set: { 'overleaf.history.conversionFailed': true, }, } ) return result } await db.projects.updateOne( { _id: projectId }, { $set: { 'overleaf.history.upgradeReason': `v1-with-conversion`, }, $unset: { 'overleaf.history.upgradeFailed': true, 'overleaf.history.conversionFailed': true, }, } ) result.upgraded = true return result } async function doUpgradeForNoneWithoutConversion(project) { const result = {} const projectId = project._id try { // Logic originally from ProjectHistoryHandler.ensureHistoryExistsForProject // However sends a force resync project to project history instead // of a resync request to doc-updater const historyId = await ProjectHistoryHandler.promises.getHistoryId( projectId ) if (!historyId) { const history = await HistoryManager.promises.initializeProject() if (history && history.overleaf_id) { await ProjectHistoryHandler.promises.setHistoryId( projectId, history.overleaf_id ) } } await HistoryManager.promises.resyncProject(projectId, { force: true, origin: { kind: 'history-migration' }, }) await HistoryManager.promises.flushProject(projectId) } catch (err) { result.error = err await db.projects.updateOne( { _id: project._id }, { $set: { 'overleaf.history.upgradeFailed': true, }, } ) return result } await db.projects.updateOne( { _id: project._id }, { $set: { 'overleaf.history.display': true, 'overleaf.history.upgradedAt': new Date(), 'overleaf.history.upgradeReason': `none-without-conversion`, }, } ) result.upgraded = true return result } async function doUpgradeForNoneWithConversion(project) { const result = {} const projectId = project._id // migrateProjectHistory expects project id as a string const projectIdString = project._id.toString() try { await ProjectHistoryController.migrateProjectHistory(projectIdString) } catch (err) { // if migrateProjectHistory fails, it cleans up by deleting // the history and unsetting the history id // therefore a failed project will still look like a 'None with conversion' project result.error = err await db.projects.updateOne( { _id: projectId }, { $set: { 'overleaf.history.conversionFailed': true, }, } ) return result } await db.projects.updateOne( { _id: projectId }, { $set: { 'overleaf.history.upgradeReason': `none-with-conversion`, }, $unset: { 'overleaf.history.upgradeFailed': true, 'overleaf.history.conversionFailed': true, }, } ) result.upgraded = true return result } // Util function projectCreatedAfterFullProjectHistoryEnabled(project) { return ( project._id.getTimestamp() >= DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED ) } async function shouldPreserveHistory(project) { return await db.projectHistoryMetaData.findOne( { $and: [ { project_id: { $eq: project._id } }, { preserveHistory: { $eq: true } }, ], }, { readPreference: ReadPreference.SECONDARY } ) } async function anyDocHistoryExists(project) { return await db.docHistory.findOne( { project_id: { $eq: project._id } }, { projection: { _id: 1 }, readPreference: ReadPreference.SECONDARY, } ) } async function anyDocHistoryIndexExists(project) { return await db.docHistoryIndex.findOne( { project_id: { $eq: project._id } }, { projection: { _id: 1 }, readPreference: ReadPreference.SECONDARY, } ) } async function convertLargeDocsToFile(projectId, userId) { const docs = await ProjectEntityHandler.promises.getAllDocs(projectId) let convertedDocCount = 0 for (const doc of Object.values(docs)) { const sizeBound = JSON.stringify(doc.lines) if (docIsTooLarge(sizeBound, doc.lines, Settings.max_doc_length)) { await ProjectEntityUpdateHandler.promises.convertDocToFile( projectId, doc._id, userId, null ) convertedDocCount++ } } return convertedDocCount } // check whether the total size of the document in characters exceeds the // maxDocLength. // // Copied from document-updater: // https://github.com/overleaf/internal/blob/74adfbebda5f3c2c37d9937f0db5c4106ecde492/services/document-updater/app/js/Limits.js#L18 function docIsTooLarge(estimatedSize, lines, maxDocLength) { if (estimatedSize <= maxDocLength) { return false // definitely under the limit, no need to calculate the total size } // calculate the total size, bailing out early if the size limit is reached let size = 0 for (const line of lines) { size += line.length + 1 // include the newline if (size > maxDocLength) return true } // since we didn't hit the limit in the loop, the document is within the allowed length return false } module.exports = { determineProjectHistoryType, getUpgradeFunctionForType, upgradeProject, convertLargeDocsToFile, }