HistoryUpgradeHelper.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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. const historyId = await ProjectHistoryHandler.promises.getHistoryId(
  168. projectId
  169. )
  170. if (!historyId) {
  171. const history = await HistoryManager.promises.initializeProject()
  172. if (history && history.overleaf_id) {
  173. await ProjectHistoryHandler.promises.setHistoryId(
  174. projectId,
  175. history.overleaf_id
  176. )
  177. }
  178. }
  179. await HistoryManager.promises.resyncProject(projectId, {
  180. force: true,
  181. origin: { kind: 'history-migration' },
  182. })
  183. await HistoryManager.promises.flushProject(projectId)
  184. } catch (err) {
  185. result.error = err
  186. await db.projects.updateOne(
  187. { _id: project._id },
  188. {
  189. $set: {
  190. 'overleaf.history.upgradeFailed': true,
  191. },
  192. }
  193. )
  194. return result
  195. }
  196. await db.projects.updateOne(
  197. { _id: project._id },
  198. {
  199. $set: {
  200. 'overleaf.history.display': true,
  201. 'overleaf.history.upgradedAt': new Date(),
  202. 'overleaf.history.upgradeReason': `none-without-conversion`,
  203. },
  204. }
  205. )
  206. result.upgraded = true
  207. return result
  208. }
  209. async function doUpgradeForNoneWithConversion(project) {
  210. const result = {}
  211. const projectId = project._id
  212. // migrateProjectHistory expects project id as a string
  213. const projectIdString = project._id.toString()
  214. try {
  215. await ProjectHistoryController.migrateProjectHistory(projectIdString)
  216. } catch (err) {
  217. // if migrateProjectHistory fails, it cleans up by deleting
  218. // the history and unsetting the history id
  219. // therefore a failed project will still look like a 'None with conversion' project
  220. result.error = err
  221. await db.projects.updateOne(
  222. { _id: projectId },
  223. {
  224. $set: {
  225. 'overleaf.history.conversionFailed': true,
  226. },
  227. }
  228. )
  229. return result
  230. }
  231. await db.projects.updateOne(
  232. { _id: projectId },
  233. {
  234. $set: {
  235. 'overleaf.history.upgradeReason': `none-with-conversion`,
  236. },
  237. $unset: {
  238. 'overleaf.history.upgradeFailed': true,
  239. 'overleaf.history.conversionFailed': true,
  240. },
  241. }
  242. )
  243. result.upgraded = true
  244. return result
  245. }
  246. // Util
  247. function projectCreatedAfterFullProjectHistoryEnabled(project) {
  248. return (
  249. project._id.getTimestamp() >= DATETIME_WHEN_FULL_PROJECT_HISTORY_ENABLED
  250. )
  251. }
  252. async function shouldPreserveHistory(project) {
  253. return await db.projectHistoryMetaData.findOne(
  254. {
  255. $and: [
  256. { project_id: { $eq: project._id } },
  257. { preserveHistory: { $eq: true } },
  258. ],
  259. },
  260. { readPreference: ReadPreference.SECONDARY }
  261. )
  262. }
  263. async function anyDocHistoryExists(project) {
  264. return await db.docHistory.findOne(
  265. { project_id: { $eq: project._id } },
  266. {
  267. projection: { _id: 1 },
  268. readPreference: ReadPreference.SECONDARY,
  269. }
  270. )
  271. }
  272. async function anyDocHistoryIndexExists(project) {
  273. return await db.docHistoryIndex.findOne(
  274. { project_id: { $eq: project._id } },
  275. {
  276. projection: { _id: 1 },
  277. readPreference: ReadPreference.SECONDARY,
  278. }
  279. )
  280. }
  281. async function convertLargeDocsToFile(projectId, userId) {
  282. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  283. let convertedDocCount = 0
  284. for (const doc of Object.values(docs)) {
  285. const sizeBound = JSON.stringify(doc.lines)
  286. if (docIsTooLarge(sizeBound, doc.lines, Settings.max_doc_length)) {
  287. await ProjectEntityUpdateHandler.promises.convertDocToFile(
  288. projectId,
  289. doc._id,
  290. userId
  291. )
  292. convertedDocCount++
  293. }
  294. }
  295. return convertedDocCount
  296. }
  297. // check whether the total size of the document in characters exceeds the
  298. // maxDocLength.
  299. //
  300. // Copied from document-updater:
  301. // https://github.com/overleaf/internal/blob/74adfbebda5f3c2c37d9937f0db5c4106ecde492/services/document-updater/app/js/Limits.js#L18
  302. function docIsTooLarge(estimatedSize, lines, maxDocLength) {
  303. if (estimatedSize <= maxDocLength) {
  304. return false // definitely under the limit, no need to calculate the total size
  305. }
  306. // calculate the total size, bailing out early if the size limit is reached
  307. let size = 0
  308. for (const line of lines) {
  309. size += line.length + 1 // include the newline
  310. if (size > maxDocLength) return true
  311. }
  312. // since we didn't hit the limit in the loop, the document is within the allowed length
  313. return false
  314. }
  315. module.exports = {
  316. determineProjectHistoryType,
  317. getUpgradeFunctionForType,
  318. upgradeProject,
  319. convertLargeDocsToFile,
  320. }