fix_malformed_filetree.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. /**
  2. * This script fixes problems found by the find_malformed_filetrees.js script.
  3. *
  4. * The script takes a single argument --logs pointing at the output of a
  5. * previous run of the find_malformed_filetrees.js script.
  6. *
  7. * Alternatively, use an adhoc file: --logs=<(echo '{"projectId":"...","path":"..."}')
  8. */
  9. import mongodb from 'mongodb-legacy'
  10. import { db } from '../app/src/infrastructure/mongodb.js'
  11. import ProjectLocator from '../app/src/Features/Project/ProjectLocator.js'
  12. import minimist from 'minimist'
  13. import readline from 'node:readline'
  14. import fs from 'node:fs'
  15. import logger from '@overleaf/logger'
  16. const { ObjectId } = mongodb
  17. const argv = minimist(process.argv.slice(2), {
  18. string: ['logs'],
  19. })
  20. let gracefulShutdownInitiated = false
  21. process.on('SIGINT', handleSignal)
  22. process.on('SIGTERM', handleSignal)
  23. function handleSignal() {
  24. gracefulShutdownInitiated = true
  25. console.warn('graceful shutdown initiated, draining queue')
  26. }
  27. const STATS = {
  28. processedLines: 0,
  29. success: 0,
  30. alreadyProcessed: 0,
  31. hash: 0,
  32. failed: 0,
  33. unmatched: 0,
  34. }
  35. function logStats() {
  36. console.log(
  37. JSON.stringify({
  38. time: new Date(),
  39. gracefulShutdownInitiated,
  40. ...STATS,
  41. })
  42. )
  43. }
  44. setInterval(logStats, 10_000)
  45. async function main() {
  46. const rl = readline.createInterface({
  47. input: fs.createReadStream(argv.logs),
  48. })
  49. for await (const line of rl) {
  50. if (gracefulShutdownInitiated) break
  51. STATS.processedLines++
  52. if (!line.startsWith('{')) continue
  53. try {
  54. const { projectId, path, _id } = JSON.parse(line)
  55. await processBadPath(projectId, path, _id)
  56. } catch (err) {
  57. STATS.failed++
  58. logger.err({ line, err }, 'failed to fix tree')
  59. }
  60. }
  61. }
  62. async function processBadPath(projectId, mongoPath, _id) {
  63. let modifiedCount
  64. if (isRootFolder(mongoPath)) {
  65. modifiedCount = await fixRootFolder(projectId)
  66. } else if (isArrayElement(mongoPath)) {
  67. modifiedCount = await removeNulls(projectId, parentPath(mongoPath))
  68. } else if (isArray(mongoPath)) {
  69. modifiedCount = await fixArray(projectId, mongoPath)
  70. } else if (isFolderId(mongoPath)) {
  71. modifiedCount = await fixFolderId(projectId, mongoPath)
  72. } else if (isDocOrFileId(mongoPath)) {
  73. modifiedCount = await removeElementsWithoutIds(
  74. projectId,
  75. parentPath(parentPath(mongoPath))
  76. )
  77. } else if (isName(mongoPath)) {
  78. modifiedCount = await fixName(projectId, mongoPath)
  79. } else if (isHash(mongoPath)) {
  80. console.error(`Missing file hash: ${projectId}/${_id} (${mongoPath})`)
  81. console.error('SaaS: likely needs filestore restore')
  82. console.error('Server Pro: please reach out to support')
  83. STATS.hash++
  84. return
  85. } else {
  86. console.error(`Unexpected mongo path: ${mongoPath}`)
  87. STATS.unmatched++
  88. return
  89. }
  90. if (modifiedCount === 0) {
  91. STATS.alreadyProcessed++
  92. } else {
  93. STATS.success++
  94. }
  95. }
  96. function isRootFolder(path) {
  97. return path === 'rootFolder.0'
  98. }
  99. function isArray(path) {
  100. return /\.(docs|folders|fileRefs)$/.test(path)
  101. }
  102. function isArrayElement(path) {
  103. return /\.\d+$/.test(path)
  104. }
  105. function isFolderId(path) {
  106. return /\.folders\.\d+\._id$/.test(path)
  107. }
  108. function isDocOrFileId(path) {
  109. return /\.(docs|fileRefs)\.\d+\._id$/.test(path)
  110. }
  111. function isName(path) {
  112. return /\.name$/.test(path)
  113. }
  114. function isHash(path) {
  115. return /\.hash$/.test(path)
  116. }
  117. function parentPath(path) {
  118. return path.slice(0, path.lastIndexOf('.'))
  119. }
  120. /**
  121. * If the root folder structure is missing, set it up
  122. */
  123. async function fixRootFolder(projectId) {
  124. const result = await db.projects.updateOne(
  125. { _id: projectId, rootFolder: [] },
  126. {
  127. $set: {
  128. rootFolder: [
  129. {
  130. _id: new ObjectId(),
  131. name: 'rootFolder',
  132. folders: [],
  133. docs: [],
  134. fileRefs: [],
  135. },
  136. ],
  137. },
  138. }
  139. )
  140. return result.modifiedCount
  141. }
  142. /**
  143. * Remove all nulls from the given docs/files/folders array
  144. */
  145. async function removeNulls(projectId, path) {
  146. const result = await db.projects.updateOne(
  147. { _id: projectId, [path]: { $type: 'array' } },
  148. { $pull: { [path]: null } }
  149. )
  150. return result.modifiedCount
  151. }
  152. /**
  153. * If the element at the given path is not an array, set it to an empty array
  154. */
  155. async function fixArray(projectId, path) {
  156. const result = await db.projects.updateOne(
  157. { _id: projectId, [path]: { $not: { $type: 'array' } } },
  158. { $set: { [path]: [] } }
  159. )
  160. return result.modifiedCount
  161. }
  162. /**
  163. * Generate a missing id for a folder
  164. */
  165. async function fixFolderId(projectId, path) {
  166. const result = await db.projects.updateOne(
  167. { _id: projectId, [path]: { $exists: false } },
  168. { $set: { [path]: new ObjectId() } }
  169. )
  170. return result.modifiedCount
  171. }
  172. /**
  173. * Remove elements that don't have ids in the array at the given path
  174. */
  175. async function removeElementsWithoutIds(projectId, path) {
  176. const result = await db.projects.updateOne(
  177. { _id: projectId, [path]: { $type: 'array' } },
  178. { $pull: { [path]: { _id: null } } }
  179. )
  180. return result.modifiedCount
  181. }
  182. /**
  183. * Give a name to a file/doc/folder that doesn't have one
  184. */
  185. async function fixName(projectId, path) {
  186. const project = await db.projects.findOne(
  187. { _id: projectId },
  188. { projection: { rootFolder: 1 } }
  189. )
  190. const arrayPath = parentPath(parentPath(path))
  191. const array = ProjectLocator.findElementByMongoPath(project, arrayPath)
  192. const existingNames = new Set(array.map(x => x.name))
  193. const name = findUniqueName(existingNames)
  194. const result = await db.projects.updateOne(
  195. { _id: projectId, [path]: { $in: [null, ''] } },
  196. { $set: { [path]: name } }
  197. )
  198. return result.modifiedCount
  199. }
  200. function findUniqueName(existingFilenames) {
  201. let index = 0
  202. let filename = 'untitled'
  203. while (existingFilenames.has(filename)) {
  204. index += 1
  205. filename = `untitled-${index}`
  206. }
  207. return filename
  208. }
  209. try {
  210. try {
  211. await main()
  212. } finally {
  213. logStats()
  214. }
  215. if (STATS.failed > 0) {
  216. process.exit(Math.min(STATS.failed, 99))
  217. } else if (STATS.hash > 0) {
  218. process.exit(100)
  219. } else if (STATS.unmatched > 0) {
  220. process.exit(101)
  221. } else {
  222. process.exit(0)
  223. }
  224. } catch (error) {
  225. console.error(error)
  226. process.exit(1)
  227. }