check_project_files.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { scriptRunner } from './lib/ScriptRunner.mjs'
  2. import Path from 'node:path'
  3. import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js'
  4. import DocumentUpdaterHandler from '../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
  5. import ProjectGetter from '../app/src/Features/Project/ProjectGetter.mjs'
  6. import ProjectEntityMongoUpdateHandler from '../app/src/Features/Project/ProjectEntityMongoUpdateHandler.mjs'
  7. import { waitForDb, db, ObjectId } from '../app/src/infrastructure/mongodb.js'
  8. import HistoryManager from '../app/src/Features/History/HistoryManager.mjs'
  9. import logger from '@overleaf/logger'
  10. import minimist from 'minimist'
  11. const args = minimist(process.argv.slice(2), {
  12. boolean: ['verbose', 'fix'],
  13. })
  14. const verbose = args.verbose
  15. if (!verbose) {
  16. logger.logger.level('error')
  17. }
  18. // no remaining arguments, print usage
  19. if (args._.length === 0) {
  20. console.log(
  21. 'Usage: node services/web/scripts/check_project_docs.js [--verbose] [--fix] <projectId>...'
  22. )
  23. process.exit(1)
  24. }
  25. function logDoc(projectId, path, doc, message = '') {
  26. console.log(
  27. 'projectId:',
  28. projectId,
  29. 'doc:',
  30. JSON.stringify({
  31. _id: doc._id,
  32. name: doc.name,
  33. lines: doc.lines ? doc.lines.join('\n').length : 0,
  34. rev: doc.rev,
  35. version: doc.version,
  36. ranges: typeof doc.ranges,
  37. }),
  38. path,
  39. message
  40. )
  41. }
  42. function logFile(projectId, path, file, message = '') {
  43. console.log(
  44. 'projectId:',
  45. projectId,
  46. 'file:',
  47. JSON.stringify({
  48. _id: file._id,
  49. name: file.name,
  50. linkedFileData: file.linkedFileData,
  51. hash: file.hash,
  52. size: file.size,
  53. }),
  54. path,
  55. message
  56. )
  57. }
  58. function findPathCounts(projectId, docEntries, fileEntries) {
  59. const pathCounts = new Map()
  60. const docPaths = docEntries.map(({ path }) => path)
  61. const filePaths = fileEntries.map(({ path }) => path)
  62. const allPaths = docPaths.concat(filePaths)
  63. for (const path of allPaths) {
  64. pathCounts.set(path, (pathCounts.get(path) || 0) + 1)
  65. }
  66. return pathCounts
  67. }
  68. // copied from services/web/app/src/Features/Project/ProjectDuplicator.mjs
  69. function _getFolderEntries(folder, folderPath = '/') {
  70. const docEntries = []
  71. const fileEntries = []
  72. const docs = folder.docs || []
  73. const files = folder.fileRefs || []
  74. const subfolders = folder.folders || []
  75. for (const doc of docs) {
  76. if (doc == null || doc._id == null) {
  77. continue
  78. }
  79. const path = Path.join(folderPath, doc.name)
  80. docEntries.push({ doc, path })
  81. }
  82. for (const file of files) {
  83. if (file == null || file._id == null) {
  84. continue
  85. }
  86. const path = Path.join(folderPath, file.name)
  87. fileEntries.push({ file, path })
  88. }
  89. for (const subfolder of subfolders) {
  90. if (subfolder == null || subfolder._id == null) {
  91. continue
  92. }
  93. const subfolderPath = Path.join(folderPath, subfolder.name)
  94. const subfolderEntries = _getFolderEntries(subfolder, subfolderPath)
  95. for (const docEntry of subfolderEntries.docEntries) {
  96. docEntries.push(docEntry)
  97. }
  98. for (const fileEntry of subfolderEntries.fileEntries) {
  99. fileEntries.push(fileEntry)
  100. }
  101. }
  102. return { docEntries, fileEntries }
  103. }
  104. async function getDocsInMongo(projectId) {
  105. return await db.docs
  106. .find({ project_id: new ObjectId(projectId), deleted: { $ne: true } })
  107. .toArray()
  108. }
  109. function getDocIdsInFileTree(docEntries) {
  110. return docEntries.map(({ doc }) => doc._id.toString())
  111. }
  112. function findMissingDocs(docsInMongo, docIdsInFileTree) {
  113. const missingDocs = []
  114. for (const doc of docsInMongo) {
  115. const docId = doc._id.toString()
  116. if (!docIdsInFileTree.includes(docId)) {
  117. console.log(`Found doc in docstore not in project filetree:`, docId)
  118. missingDocs.push(doc)
  119. }
  120. }
  121. return missingDocs
  122. }
  123. async function createRecoveryFolder(projectId) {
  124. const recoveryFolder = `recovered-${Date.now()}`
  125. const { folder } = await ProjectEntityMongoUpdateHandler.promises.mkdirp(
  126. new ObjectId(projectId),
  127. recoveryFolder,
  128. null // unset lastUpdatedBy
  129. )
  130. console.log('Created recovery folder:', folder._id.toString())
  131. return folder
  132. }
  133. async function restoreMissingDocs(projectId, folder, missingDocs) {
  134. for (const doc of missingDocs) {
  135. doc.name = doc.name || `unknown-file-${doc._id.toString()}`
  136. try {
  137. await ProjectEntityMongoUpdateHandler.promises.addDoc(
  138. new ObjectId(projectId),
  139. folder._id,
  140. doc,
  141. null // unset lastUpdatedBy
  142. )
  143. console.log('Restored doc to filetree:', doc._id.toString())
  144. } catch (err) {
  145. console.log(`Error adding doc to filetree:`, err)
  146. }
  147. }
  148. }
  149. async function checkProject(projectId) {
  150. try {
  151. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  152. } catch (err) {
  153. console.log(`Error flushing project ${projectId} to mongo: ${err}`)
  154. }
  155. const project = await ProjectGetter.promises.getProject(projectId, {
  156. rootFolder: true,
  157. rootDoc_id: true,
  158. })
  159. if (verbose) {
  160. console.log(`project: ${JSON.stringify(project)}`)
  161. }
  162. const { docEntries, fileEntries } = _getFolderEntries(project.rootFolder[0])
  163. console.log(
  164. `Found ${docEntries.length} docEntries and ${fileEntries.length} fileEntries`
  165. )
  166. const pathCounts = findPathCounts(projectId, docEntries, fileEntries)
  167. for (const [path, count] of pathCounts) {
  168. if (count > 1) {
  169. console.log(`Found duplicate path: ${path}`)
  170. }
  171. }
  172. let errors = 0
  173. for (const { doc, path } of docEntries) {
  174. try {
  175. const { lines, rev, version, ranges } =
  176. await DocstoreManager.promises.getDoc(projectId, doc._id)
  177. if (!lines) {
  178. throw new Error('no doclines')
  179. }
  180. if (pathCounts.get(path) > 1) {
  181. logDoc(
  182. projectId,
  183. path,
  184. { ...doc, lines, rev, version, ranges },
  185. 'duplicate path'
  186. )
  187. errors++
  188. } else if (verbose) {
  189. logDoc(projectId, path, { ...doc, lines, rev, version, ranges })
  190. }
  191. } catch (err) {
  192. logDoc(projectId, path, doc, err)
  193. errors++
  194. }
  195. }
  196. for (const { file, path } of fileEntries) {
  197. try {
  198. const { contentLength: fileSize } =
  199. await HistoryManager.promises.requestBlobWithProjectId(
  200. projectId,
  201. file.hash,
  202. 'HEAD'
  203. )
  204. if (pathCounts.get(path) > 1) {
  205. logFile(projectId, path, { ...file, fileSize }, 'duplicate path')
  206. errors++
  207. } else if (verbose) {
  208. logFile(projectId, path, { ...file, fileSize })
  209. }
  210. } catch (err) {
  211. logFile(projectId, path, file, err)
  212. errors++
  213. }
  214. }
  215. // now look for docs in the docstore that are not in the project filetree
  216. const docsInMongo = await getDocsInMongo(projectId)
  217. const docIdsInFileTree = getDocIdsInFileTree(docEntries)
  218. const missingDocs = findMissingDocs(docsInMongo, docIdsInFileTree)
  219. if (args.fix && missingDocs.length > 0) {
  220. console.log('Restoring missing docs to filetree...')
  221. const folder = await createRecoveryFolder(projectId)
  222. await restoreMissingDocs(projectId, folder, missingDocs)
  223. }
  224. if (errors > 0) {
  225. console.log(`Errors found in project: ${projectId}`)
  226. } else {
  227. console.log(`No errors found in project: ${projectId}`)
  228. }
  229. }
  230. async function main() {
  231. await waitForDb()
  232. for (const projectId of args._) {
  233. await checkProject(projectId)
  234. }
  235. }
  236. scriptRunner(main, args)
  237. .then(() => {
  238. console.log('DONE')
  239. process.exit(0)
  240. })
  241. .catch(err => {
  242. console.error(err)
  243. process.exit(1)
  244. })