fix_malformed_filetree.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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. {
  126. _id: new ObjectId(projectId),
  127. rootFolder: { $size: 0 },
  128. },
  129. {
  130. $set: {
  131. rootFolder: [
  132. {
  133. _id: new ObjectId(),
  134. name: 'rootFolder',
  135. folders: [],
  136. docs: [],
  137. fileRefs: [],
  138. },
  139. ],
  140. },
  141. }
  142. )
  143. return result.modifiedCount
  144. }
  145. /**
  146. * Remove all nulls from the given docs/files/folders array
  147. */
  148. async function removeNulls(projectId, path) {
  149. const result = await db.projects.updateOne(
  150. { _id: new ObjectId(projectId), [path]: { $type: 'array' } },
  151. { $pull: { [path]: null } }
  152. )
  153. return result.modifiedCount
  154. }
  155. /**
  156. * If the element at the given path is not an array, set it to an empty array
  157. */
  158. async function fixArray(projectId, path) {
  159. const result = await db.projects.updateOne(
  160. { _id: new ObjectId(projectId), [path]: { $not: { $type: 'array' } } },
  161. { $set: { [path]: [] } }
  162. )
  163. return result.modifiedCount
  164. }
  165. /**
  166. * Generate a missing id for a folder
  167. */
  168. async function fixFolderId(projectId, path) {
  169. const result = await db.projects.updateOne(
  170. { _id: new ObjectId(projectId), [path]: { $exists: false } },
  171. { $set: { [path]: new ObjectId() } }
  172. )
  173. return result.modifiedCount
  174. }
  175. /**
  176. * Remove elements that don't have ids in the array at the given path
  177. */
  178. async function removeElementsWithoutIds(projectId, path) {
  179. const result = await db.projects.updateOne(
  180. { _id: new ObjectId(projectId), [path]: { $type: 'array' } },
  181. { $pull: { [path]: { _id: null } } }
  182. )
  183. return result.modifiedCount
  184. }
  185. /**
  186. * Give a name to a file/doc/folder that doesn't have one
  187. */
  188. async function fixName(projectId, path) {
  189. const project = await db.projects.findOne(
  190. { _id: new ObjectId(projectId) },
  191. { projection: { rootFolder: 1 } }
  192. )
  193. const arrayPath = parentPath(parentPath(path))
  194. const array = ProjectLocator.findElementByMongoPath(project, arrayPath)
  195. const existingNames = new Set(array.map(x => x.name))
  196. const name =
  197. path === 'rootFolder.0.name' ? 'rootFolder' : findUniqueName(existingNames)
  198. const result = await db.projects.updateOne(
  199. { _id: new ObjectId(projectId), [path]: { $in: [null, ''] } },
  200. { $set: { [path]: name } }
  201. )
  202. return result.modifiedCount
  203. }
  204. function findUniqueName(existingFilenames) {
  205. let index = 0
  206. let filename = 'untitled'
  207. while (existingFilenames.has(filename)) {
  208. index += 1
  209. filename = `untitled-${index}`
  210. }
  211. return filename
  212. }
  213. try {
  214. try {
  215. await main()
  216. } finally {
  217. logStats()
  218. }
  219. if (STATS.failed > 0) {
  220. process.exit(Math.min(STATS.failed, 99))
  221. } else if (STATS.hash > 0) {
  222. process.exit(100)
  223. } else if (STATS.unmatched > 0) {
  224. process.exit(101)
  225. } else {
  226. process.exit(0)
  227. }
  228. } catch (error) {
  229. console.error(error)
  230. process.exit(1)
  231. }