fix_malformed_filetree.mjs 7.4 KB

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