HistoryUpgradeHelper.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. const { ReadPreference, ObjectId } = require('mongodb')
  2. const { db } = require('../../app/src/infrastructure/mongodb')
  3. const Settings = require('@overleaf/settings')
  4. const ProjectHistoryHandler = require('../../app/src/Features/Project/ProjectHistoryHandler')
  5. const HistoryManager = require('../../app/src/Features/History/HistoryManager')
  6. const ProjectHistoryController = require('../../modules/admin-panel/app/src/ProjectHistoryController')
  7. const ProjectEntityHandler = require('../../app/src/Features/Project/ProjectEntityHandler')
  8. const ProjectEntityUpdateHandler = require('../../app/src/Features/Project/ProjectEntityUpdateHandler')
  9. // Timestamp of when 'Enable history for SL in background' release
  10. const ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = '5a8d8a370000000000000000'
  11. const OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED = new ObjectId(
  12. ID_WHEN_FULL_PROJECT_HISTORY_ENABLED
  13. )
  14. const DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED =
  15. OBJECT_ID_WHEN_FULL_PROJECT_HISTORY_ENABLED.getTimestamp()
  16. async function determineProjectHistoryType(project) {
  17. if (project.overleaf && project.overleaf.history) {
  18. if (project.overleaf.history.upgradeFailed) {
  19. return 'UpgradeFailed'
  20. }
  21. if (project.overleaf.history.conversionFailed) {
  22. return 'ConversionFailed'
  23. }
  24. }
  25. if (
  26. project.overleaf &&
  27. project.overleaf.history &&
  28. project.overleaf.history.id
  29. ) {
  30. if (project.overleaf.history.display) {
  31. // v2: full project history, do nothing
  32. return 'V2'
  33. } else {
  34. if (projectCreatedAfterFullProjectHistoryEnabled(project)) {
  35. // IF project initialised after full project history enabled for all projects
  36. // THEN project history should contain all information we need, without intervention
  37. return 'V1WithoutConversion'
  38. } else {
  39. // ELSE SL history may predate full project history
  40. // THEN delete full project history and convert their SL history to full project history
  41. // --
  42. // TODO: how to verify this, can get rough start date of SL history, but not full project history
  43. const preserveHistory = await shouldPreserveHistory(project)
  44. const anyDocHistory = await anyDocHistoryExists(project)
  45. const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
  46. if (preserveHistory) {
  47. if (anyDocHistory || anyDocHistoryIndex) {
  48. // if SL history exists that we need to preserve, then we must convert
  49. return 'V1WithConversion'
  50. } else {
  51. // otherwise just upgrade without conversion
  52. return 'V1WithoutConversion'
  53. }
  54. } else {
  55. // if preserveHistory false, then max 7 days of SL history
  56. // but v1 already record to both histories, so safe to upgrade
  57. return 'V1WithoutConversion'
  58. }
  59. }
  60. }
  61. } else {
  62. const preserveHistory = await shouldPreserveHistory(project)
  63. const anyDocHistory = await anyDocHistoryExists(project)
  64. const anyDocHistoryIndex = await anyDocHistoryIndexExists(project)
  65. if (anyDocHistory || anyDocHistoryIndex) {
  66. // IF there is SL history ->
  67. if (preserveHistory) {
  68. // that needs to be preserved:
  69. // THEN initialise full project history and convert SL history to full project history
  70. return 'NoneWithConversion'
  71. } else {
  72. return 'NoneWithTemporaryHistory'
  73. }
  74. } else {
  75. // ELSE there is not any SL history ->
  76. // THEN initialise full project history and sync with current content
  77. return 'NoneWithoutConversion'
  78. }
  79. }
  80. }
  81. async function upgradeProject(project) {
  82. const historyType = await determineProjectHistoryType(project)
  83. if (historyType === 'V2') {
  84. return { historyType, upgraded: true }
  85. }
  86. const upgradeFn = getUpgradeFunctionForType(historyType)
  87. if (!upgradeFn) {
  88. return { error: 'unsupported history type' }
  89. }
  90. const result = await upgradeFn(project)
  91. result.historyType = historyType
  92. return result
  93. }
  94. // Do upgrades/conversion:
  95. function getUpgradeFunctionForType(historyType) {
  96. return UpgradeFunctionMapping[historyType]
  97. }
  98. const UpgradeFunctionMapping = {
  99. NoneWithoutConversion: doUpgradeForNoneWithoutConversion,
  100. UpgradeFailed: doUpgradeForNoneWithoutConversion,
  101. ConversionFailed: doUpgradeForNoneWithConversion,
  102. V1WithoutConversion: doUpgradeForV1WithoutConversion,
  103. V1WithConversion: doUpgradeForV1WithConversion,
  104. NoneWithConversion: doUpgradeForNoneWithConversion,
  105. NoneWithTemporaryHistory: doUpgradeForNoneWithConversion,
  106. }
  107. async function doUpgradeForV1WithoutConversion(project) {
  108. await db.projects.updateOne(
  109. { _id: project._id },
  110. {
  111. $set: {
  112. 'overleaf.history.display': true,
  113. 'overleaf.history.upgradedAt': new Date(),
  114. 'overleaf.history.upgradeReason': `v1-without-sl-history`,
  115. },
  116. }
  117. )
  118. return { upgraded: true }
  119. }
  120. async function doUpgradeForV1WithConversion(project) {
  121. const result = {}
  122. const projectId = project._id
  123. // migrateProjectHistory expects project id as a string
  124. const projectIdString = project._id.toString()
  125. try {
  126. // We treat these essentially as None projects, the V1 history is irrelevant,
  127. // so we will delete it, and do a conversion as if we're a None project
  128. await ProjectHistoryController.deleteProjectHistory(projectIdString)
  129. await ProjectHistoryController.migrateProjectHistory(projectIdString)
  130. } catch (err) {
  131. // if migrateProjectHistory fails, it cleans up by deleting
  132. // the history and unsetting the history id
  133. // therefore a failed project will still look like a 'None with conversion' project
  134. result.error = err
  135. await db.projects.updateOne(
  136. { _id: projectId },
  137. {
  138. $set: {
  139. 'overleaf.history.conversionFailed': true,
  140. },
  141. }
  142. )
  143. return result
  144. }
  145. await db.projects.updateOne(
  146. { _id: projectId },
  147. {
  148. $set: {
  149. 'overleaf.history.upgradeReason': `v1-with-conversion`,
  150. },
  151. $unset: {
  152. 'overleaf.history.upgradeFailed': true,
  153. 'overleaf.history.conversionFailed': true,
  154. },
  155. }
  156. )
  157. result.upgraded = true
  158. return result
  159. }
  160. async function doUpgradeForNoneWithoutConversion(project) {
  161. const result = {}
  162. const projectId = project._id
  163. try {
  164. // Logic originally from ProjectHistoryHandler.ensureHistoryExistsForProject
  165. // However sends a force resync project to project history instead
  166. // of a resync request to doc-updater
  167. let historyId = await ProjectHistoryHandler.promises.getHistoryId(projectId)
  168. if (historyId == null) {
  169. historyId = await HistoryManager.promises.initializeProject()
  170. if (historyId != null) {
  171. await ProjectHistoryHandler.promises.setHistoryId(projectId, historyId)
  172. }
  173. }
  174. await HistoryManager.promises.resyncProject(projectId, {
  175. force: true,
  176. origin: { kind: 'history-migration' },
  177. })
  178. await HistoryManager.promises.flushProject(projectId)
  179. } catch (err) {
  180. result.error = err
  181. await db.projects.updateOne(
  182. { _id: project._id },
  183. {
  184. $set: {
  185. 'overleaf.history.upgradeFailed': true,
  186. },
  187. }
  188. )
  189. return result
  190. }
  191. await db.projects.updateOne(
  192. { _id: project._id },
  193. {
  194. $set: {
  195. 'overleaf.history.display': true,
  196. 'overleaf.history.upgradedAt': new Date(),
  197. 'overleaf.history.upgradeReason': `none-without-conversion`,
  198. },
  199. }
  200. )
  201. result.upgraded = true
  202. return result
  203. }
  204. async function doUpgradeForNoneWithConversion(project) {
  205. const result = {}
  206. const projectId = project._id
  207. // migrateProjectHistory expects project id as a string
  208. const projectIdString = project._id.toString()
  209. try {
  210. await ProjectHistoryController.migrateProjectHistory(projectIdString)
  211. } catch (err) {
  212. // if migrateProjectHistory fails, it cleans up by deleting
  213. // the history and unsetting the history id
  214. // therefore a failed project will still look like a 'None with conversion' project
  215. result.error = err
  216. await db.projects.updateOne(
  217. { _id: projectId },
  218. {
  219. $set: {
  220. 'overleaf.history.conversionFailed': true,
  221. },
  222. }
  223. )
  224. return result
  225. }
  226. await db.projects.updateOne(
  227. { _id: projectId },
  228. {
  229. $set: {
  230. 'overleaf.history.upgradeReason': `none-with-conversion`,
  231. },
  232. $unset: {
  233. 'overleaf.history.upgradeFailed': true,
  234. 'overleaf.history.conversionFailed': true,
  235. },
  236. }
  237. )
  238. result.upgraded = true
  239. return result
  240. }
  241. // Util
  242. function projectCreatedAfterFullProjectHistoryEnabled(project) {
  243. return (
  244. project._id.getTimestamp() >= DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED
  245. )
  246. }
  247. async function shouldPreserveHistory(project) {
  248. return await db.projectHistoryMetaData.findOne(
  249. {
  250. $and: [
  251. { project_id: { $eq: project._id } },
  252. { preserveHistory: { $eq: true } },
  253. ],
  254. },
  255. { readPreference: ReadPreference.SECONDARY }
  256. )
  257. }
  258. async function anyDocHistoryExists(project) {
  259. return await db.docHistory.findOne(
  260. { project_id: { $eq: project._id } },
  261. {
  262. projection: { _id: 1 },
  263. readPreference: ReadPreference.SECONDARY,
  264. }
  265. )
  266. }
  267. async function anyDocHistoryIndexExists(project) {
  268. return await db.docHistoryIndex.findOne(
  269. { project_id: { $eq: project._id } },
  270. {
  271. projection: { _id: 1 },
  272. readPreference: ReadPreference.SECONDARY,
  273. }
  274. )
  275. }
  276. async function convertLargeDocsToFile(projectId, userId) {
  277. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  278. let convertedDocCount = 0
  279. for (const doc of Object.values(docs)) {
  280. const sizeBound = JSON.stringify(doc.lines)
  281. if (docIsTooLarge(sizeBound, doc.lines, Settings.max_doc_length)) {
  282. await ProjectEntityUpdateHandler.promises.convertDocToFile(
  283. projectId,
  284. doc._id,
  285. userId,
  286. null
  287. )
  288. convertedDocCount++
  289. }
  290. }
  291. return convertedDocCount
  292. }
  293. // check whether the total size of the document in characters exceeds the
  294. // maxDocLength.
  295. //
  296. // Copied from document-updater:
  297. // https://github.com/overleaf/internal/blob/74adfbebda5f3c2c37d9937f0db5c4106ecde492/services/document-updater/app/js/Limits.js#L18
  298. function docIsTooLarge(estimatedSize, lines, maxDocLength) {
  299. if (estimatedSize <= maxDocLength) {
  300. return false // definitely under the limit, no need to calculate the total size
  301. }
  302. // calculate the total size, bailing out early if the size limit is reached
  303. let size = 0
  304. for (const line of lines) {
  305. size += line.length + 1 // include the newline
  306. if (size > maxDocLength) return true
  307. }
  308. // since we didn't hit the limit in the loop, the document is within the allowed length
  309. return false
  310. }
  311. module.exports = {
  312. determineProjectHistoryType,
  313. getUpgradeFunctionForType,
  314. upgradeProject,
  315. convertLargeDocsToFile,
  316. }