UpdateMerger.mjs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { callbackify } from 'node:util'
  2. import _ from 'lodash'
  3. import fsPromises from 'node:fs/promises'
  4. import fs from 'node:fs'
  5. import logger from '@overleaf/logger'
  6. import EditorController from '../Editor/EditorController.mjs'
  7. import FileTypeManager from '../Uploads/FileTypeManager.mjs'
  8. import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
  9. import crypto from 'node:crypto'
  10. import Settings from '@overleaf/settings'
  11. import { pipeline } from 'node:stream/promises'
  12. async function mergeUpdate(userId, projectId, path, updateRequest, source) {
  13. const fsPath = await writeUpdateToDisk(projectId, updateRequest)
  14. try {
  15. // note: important to await here so file reading finishes before cleaning up below
  16. return await _mergeUpdate(userId, projectId, path, fsPath, source)
  17. } finally {
  18. // note: not awaited or thrown
  19. fsPromises.unlink(fsPath).catch(err => {
  20. logger.err({ err, projectId, fsPath }, 'error deleting file')
  21. })
  22. }
  23. }
  24. async function writeUpdateToDisk(projectId, updateStream) {
  25. const fsPath = `${
  26. Settings.path.dumpFolder
  27. }/${projectId}_${crypto.randomUUID()}`
  28. const writeStream = fs.createWriteStream(fsPath)
  29. try {
  30. await pipeline(updateStream, writeStream)
  31. } catch (err) {
  32. try {
  33. await fsPromises.unlink(fsPath)
  34. } catch (err) {
  35. logger.error({ err, projectId, fsPath }, 'error deleting file')
  36. }
  37. throw err
  38. }
  39. return fsPath
  40. }
  41. async function _findExistingFileType(projectId, path) {
  42. const { docs, files } =
  43. await ProjectEntityHandler.promises.getAllEntities(projectId)
  44. if (_.some(docs, d => d.path === path)) {
  45. return 'doc'
  46. }
  47. if (_.some(files, f => f.path === path)) {
  48. return 'file'
  49. }
  50. return null
  51. }
  52. async function _determineFileType(projectId, path, fsPath) {
  53. // check if there is an existing file with the same path (we either need
  54. // to overwrite it or delete it)
  55. const existingFileType = await _findExistingFileType(projectId, path)
  56. // determine whether the update should create a doc or binary file
  57. const { binary, encoding } = await FileTypeManager.promises.getType(
  58. path,
  59. fsPath,
  60. existingFileType
  61. )
  62. // If we receive a non-utf8 encoding, we won't be able to keep things in
  63. // sync, so we'll treat non-utf8 files as binary
  64. const isBinary = binary || encoding !== 'utf-8'
  65. // Existing | Update | Resulting file type
  66. // ---------|-----------|--------------------
  67. // file | isBinary | file
  68. // file | !isBinary | file
  69. // doc | isBinary | file
  70. // doc | !isBinary | doc
  71. // null | isBinary | file
  72. // null | !isBinary | doc
  73. // if a binary file already exists, always keep it as a binary file
  74. // even if the update looks like a text file
  75. if (existingFileType === 'file') {
  76. return 'file'
  77. } else {
  78. return isBinary ? 'file' : 'doc'
  79. }
  80. }
  81. async function _mergeUpdate(userId, projectId, path, fsPath, source) {
  82. const fileType = await _determineFileType(projectId, path, fsPath)
  83. if (fileType === 'file') {
  84. const { file, folder } = await _processFile(
  85. projectId,
  86. fsPath,
  87. path,
  88. source,
  89. userId
  90. )
  91. return {
  92. projectId,
  93. entityType: 'file',
  94. entityId: file._id,
  95. rev: file.rev,
  96. folderId: folder._id,
  97. }
  98. } else if (fileType === 'doc') {
  99. const { doc, folder } = await _processDoc(
  100. projectId,
  101. userId,
  102. fsPath,
  103. path,
  104. source
  105. )
  106. return {
  107. projectId,
  108. entityType: 'doc',
  109. entityId: doc._id,
  110. rev: doc.rev,
  111. folderId: folder._id,
  112. }
  113. } else {
  114. throw new Error('unrecognized file')
  115. }
  116. }
  117. async function deleteUpdate(userId, projectId, path, source) {
  118. try {
  119. return await EditorController.promises.deleteEntityWithPath(
  120. projectId,
  121. path,
  122. source,
  123. userId
  124. )
  125. } catch (err) {
  126. logger.warn(
  127. { err, userId, projectId, path, source },
  128. 'failed to delete entity'
  129. )
  130. }
  131. }
  132. async function _processDoc(projectId, userId, fsPath, path, source) {
  133. const docLines = await _readFileIntoTextArray(fsPath)
  134. logger.debug({ docLines }, 'processing doc update from tpds')
  135. const doc = await EditorController.promises.upsertDocWithPath(
  136. projectId,
  137. path,
  138. docLines,
  139. source,
  140. userId
  141. )
  142. return doc
  143. }
  144. async function _processFile(projectId, fsPath, path, source, userId) {
  145. const { file, folder } = await EditorController.promises.upsertFileWithPath(
  146. projectId,
  147. path,
  148. fsPath,
  149. null,
  150. source,
  151. userId
  152. )
  153. return { file, folder }
  154. }
  155. async function _readFileIntoTextArray(path) {
  156. let content = await fsPromises.readFile(path, 'utf8')
  157. if (content == null) {
  158. content = ''
  159. }
  160. const lines = content.split(/\r\n|\n|\r/)
  161. return lines
  162. }
  163. async function createFolder(projectId, path, userId) {
  164. const { lastFolder: folder } = await EditorController.promises.mkdirp(
  165. projectId,
  166. path,
  167. userId
  168. )
  169. return folder
  170. }
  171. export default {
  172. mergeUpdate: callbackify(mergeUpdate),
  173. _mergeUpdate: callbackify(_mergeUpdate),
  174. deleteUpdate: callbackify(deleteUpdate),
  175. createFolder: callbackify(createFolder),
  176. promises: {
  177. mergeUpdate,
  178. _mergeUpdate, // called by GitBridgeHandler
  179. deleteUpdate,
  180. createFolder,
  181. },
  182. }