fix_malformed_filetree.mjs 6.8 KB

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