manager.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import 'server-only'
  2. import type { Dirent } from 'node:fs'
  3. import {
  4. constants,
  5. copyFile,
  6. cp,
  7. lstat,
  8. mkdir,
  9. readdir,
  10. rename,
  11. rm,
  12. rmdir,
  13. unlink,
  14. } from 'node:fs/promises'
  15. import path from 'node:path'
  16. import { UPLOAD_CATEGORIES } from '@/lib/upload-url'
  17. import { uploadsRoot, uploadsRoots } from '@/lib/upload-root'
  18. import {
  19. allReferencedSuffixes,
  20. existingOrganizationIds,
  21. hasAnyOrganization,
  22. referencedSuffixes,
  23. } from './references'
  24. /**
  25. * The one place uploaded files are deleted from disk.
  26. *
  27. * Every other module that wants a file gone asks here, and nothing else in
  28. * `src/` unlinks under the uploads folder (a test holds that line). The rules
  29. * this file applies to every deletion:
  30. *
  31. * 1. Only after the database change has committed. A file is released once
  32. * the rows that pointed at it are gone, so a delete that fails half way
  33. * leaves the file where its rows still expect it.
  34. * 2. Only if nothing still points at it. Several rows can share one file (a
  35. * tire set's photo copied onto a work order, a job photo sent on WhatsApp,
  36. * a logo used by the settings and by a design); `references.ts` lists every
  37. * column that can, and a file any of them still mentions is kept.
  38. * 3. Only inside the workshop's own folder for that kind of file, and only a
  39. * plain name: `<uploads>/<org>/<folder>/<name>`. A URL naming another
  40. * workshop, an unknown folder, a `..`, a symlink or a directory is left
  41. * alone, whatever row it came from.
  42. * 4. Never an exception into the caller. A failed cleanup must not fail the
  43. * delete the user asked for; it is logged, and the file stays.
  44. * 5. Into a trash first. A released file is moved to
  45. * `<uploads>/.trash/<date>/<org>/<folder>/<name>` and only deleted for good
  46. * `TRASH_DAYS` later (purgeTrash), so a mistake, a race or a stale save is
  47. * still recoverable. The trash is outside every workshop's folder, so it is
  48. * never served and never part of a workshop's own export; the server's
  49. * backup of the data folder does include it until it is purged.
  50. *
  51. * Both upload roots are cleaned (see lib/upload-root.ts), because files have
  52. * been written under each and a file under either would still be served.
  53. */
  54. const FOLDERS: ReadonlySet<string> = new Set(UPLOAD_CATEGORIES)
  55. const ORG_ID = /^[A-Za-z0-9_-]{1,64}$/
  56. const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/
  57. const STORED_URL = /^\/api\/(?:protected\/)?files\/([^/]+)\/([^/]+)\/([^/]+)$/
  58. /**
  59. * Names the upload routes generate: a random UUID and an extension, or a
  60. * video's `_orig` copy that is only there while it is compressed. The sweep
  61. * only ever considers these; a file named any other way (an import's, the
  62. * demo seed's) was put there on purpose by something else.
  63. */
  64. const GENERATED_NAME =
  65. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:_orig)?\.[A-Za-z0-9]{1,8}$/
  66. export interface StoredFile {
  67. organizationId: string
  68. folder: string
  69. name: string
  70. /** `/<folder>/<name>`: how references.ts finds rows that still use it. */
  71. suffix: string
  72. }
  73. /** A stored URL taken apart, or null when it is not one of the app's own uploads. */
  74. export function parseStoredFileUrl(url: unknown): StoredFile | null {
  75. if (typeof url !== 'string') return null
  76. const match = STORED_URL.exec(url.trim())
  77. if (!match) return null
  78. const [, organizationId, folder, name] = match
  79. if (!ORG_ID.test(organizationId) || !FOLDERS.has(folder)) return null
  80. if (!FILE_NAME.test(name) || name.includes('..')) return null
  81. return { organizationId, folder, name, suffix: `/${folder}/${name}` }
  82. }
  83. /** Where the file is under one root, or null if that would be anywhere but its own folder. */
  84. function pathUnder(root: string, file: StoredFile): string | null {
  85. const folder = path.resolve(root, file.organizationId, file.folder)
  86. const target = path.resolve(folder, file.name)
  87. return path.dirname(target) === folder ? target : null
  88. }
  89. /** Days a released file waits in the trash before it is deleted for good. */
  90. export const TRASH_DAYS = 30
  91. const TRASH = '.trash'
  92. const DAY_FOLDER = /^\d{4}-\d{2}-\d{2}$/
  93. /** Whether the path is a regular file: never a link, never a directory. */
  94. async function isPlainFile(target: string): Promise<boolean> {
  95. try {
  96. return (await lstat(target)).isFile()
  97. } catch (err) {
  98. if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
  99. throw err
  100. }
  101. }
  102. /** Whether anything at all is at this path, a broken link included. */
  103. async function taken(target: string): Promise<boolean> {
  104. return lstat(target).then(
  105. () => true,
  106. (err: NodeJS.ErrnoException) => {
  107. if (err.code === 'ENOENT') return false
  108. throw err
  109. }
  110. )
  111. }
  112. /**
  113. * Moves one regular file into the root's trash, under the day it was
  114. * released. False when it was not there or was not a plain file. A name
  115. * already in that day's trash gets a counter, so an earlier release is never
  116. * overwritten; and a trash on another disk (a folder mounted on its own) is
  117. * reached by copy and delete, since a rename cannot cross disks.
  118. */
  119. async function trashFile(root: string, target: string, file: StoredFile, now: number) {
  120. if (!(await isPlainFile(target))) return false
  121. const day = new Date(now).toISOString().slice(0, 10)
  122. const dir = path.join(path.resolve(root), TRASH, day, file.organizationId, file.folder)
  123. await mkdir(dir, { recursive: true })
  124. let destination = path.join(dir, file.name)
  125. for (let n = 1; await taken(destination); n++) {
  126. destination = path.join(dir, `${file.name}.${n}`)
  127. }
  128. try {
  129. await rename(target, destination)
  130. } catch (err) {
  131. if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
  132. await copyFile(target, destination, constants.COPYFILE_EXCL)
  133. await unlink(target)
  134. }
  135. return true
  136. }
  137. /** Deletes one regular file outright; for files nothing ever referenced. */
  138. async function deleteFile(target: string): Promise<boolean> {
  139. if (!(await isPlainFile(target))) return false
  140. await unlink(target)
  141. return true
  142. }
  143. export interface ReleaseResult {
  144. /** URLs whose file was deleted from at least one root. */
  145. removed: string[]
  146. /** URLs still used by some row, so kept. */
  147. kept: string[]
  148. /** URLs that are not this workshop's uploads, or could not be checked, so left alone. */
  149. skipped: string[]
  150. }
  151. /**
  152. * Deletes the files behind these URLs that no row uses any more. Call it
  153. * after the rows that pointed at them have been deleted or changed, with the
  154. * workshop those rows belonged to; a URL naming any other workshop is skipped.
  155. */
  156. export async function releaseFiles(
  157. urls: Iterable<string | null | undefined>,
  158. {
  159. organizationId,
  160. reason,
  161. now = Date.now(),
  162. }: { organizationId: string; reason: string; now?: number }
  163. ): Promise<ReleaseResult> {
  164. const result: ReleaseResult = { removed: [], kept: [], skipped: [] }
  165. const candidates = new Map<string, { url: string; file: StoredFile }>()
  166. for (const url of urls) {
  167. if (!url) continue
  168. const file = parseStoredFileUrl(url)
  169. if (!file || file.organizationId !== organizationId) {
  170. result.skipped.push(url)
  171. continue
  172. }
  173. if (!candidates.has(file.suffix)) candidates.set(file.suffix, { url, file })
  174. }
  175. if (candidates.size === 0) return result
  176. let inUse: Set<string>
  177. try {
  178. inUse = await referencedSuffixes([...candidates.keys()])
  179. } catch (err) {
  180. // Unable to tell whether they are in use: keep every one.
  181. console.error(`[files] could not check references (${reason}); nothing deleted:`, err)
  182. result.skipped.push(...[...candidates.values()].map((c) => c.url))
  183. return result
  184. }
  185. for (const [suffix, { url, file }] of candidates) {
  186. if (inUse.has(suffix)) {
  187. result.kept.push(url)
  188. continue
  189. }
  190. let removed = false
  191. for (const root of uploadsRoots()) {
  192. const target = pathUnder(root, file)
  193. if (!target) continue
  194. try {
  195. if (await trashFile(root, target, file, now)) removed = true
  196. } catch (err) {
  197. console.error(`[files] could not delete ${file.suffix} (${reason}):`, err)
  198. }
  199. }
  200. if (removed) result.removed.push(url)
  201. }
  202. if (result.removed.length > 0) {
  203. console.warn(
  204. `[files] ${reason}: moved ${result.removed.length} unused file(s) of ${organizationId} to the trash: ${result.removed
  205. .map((url) => parseStoredFileUrl(url)?.suffix)
  206. .join(', ')}`
  207. )
  208. }
  209. return result
  210. }
  211. /**
  212. * A file an upload has just written whose row was never saved, or the
  213. * temporary original of a video once its compressed copy exists. Nothing can
  214. * reference it, so it goes without the check; the folder and name are still
  215. * validated, and only the root it was written to is touched.
  216. */
  217. export async function discardUnsavedUpload(
  218. organizationId: string,
  219. folder: string,
  220. name: string
  221. ): Promise<void> {
  222. if (!ORG_ID.test(organizationId) || !FOLDERS.has(folder)) return
  223. if (!FILE_NAME.test(name) || name.includes('..')) return
  224. const target = pathUnder(uploadsRoot(), {
  225. organizationId,
  226. folder,
  227. name,
  228. suffix: `/${folder}/${name}`,
  229. })
  230. if (!target) return
  231. await deleteFile(target).catch((err) =>
  232. console.error(`[files] could not discard /${folder}/${name}:`, err)
  233. )
  234. }
  235. /** A workshop's own directory under one root, or null for an id that is not one. */
  236. function organizationDir(root: string, organizationId: string): string | null {
  237. if (!ORG_ID.test(organizationId)) return null
  238. const base = path.resolve(root)
  239. const dir = path.resolve(base, organizationId)
  240. return path.dirname(dir) === base ? dir : null
  241. }
  242. /**
  243. * Moves a file or folder to `destination`, merging into a folder already
  244. * there: each child is moved in turn, and a name already taken by something
  245. * else gets a counter, as in trashFile, so nothing in the trash is ever
  246. * overwritten. A trash on another disk is reached by copy and delete, since a
  247. * rename cannot cross disks.
  248. */
  249. async function moveInto(source: string, destination: string): Promise<void> {
  250. if (await taken(destination)) {
  251. const [from, to] = await Promise.all([lstat(source), lstat(destination)])
  252. if (from.isDirectory() && to.isDirectory()) {
  253. for (const child of await readdir(source)) {
  254. await moveInto(path.join(source, child), path.join(destination, child))
  255. }
  256. // Empty by now; rmdir rather than rm, so a child that somehow stayed
  257. // behind stops the removal instead of being deleted with it.
  258. await rmdir(source)
  259. return
  260. }
  261. let free = destination
  262. for (let n = 1; await taken(free); n++) free = `${destination}.${n}`
  263. destination = free
  264. }
  265. await mkdir(path.dirname(destination), { recursive: true })
  266. try {
  267. await rename(source, destination)
  268. } catch (err) {
  269. if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
  270. await cp(source, destination, {
  271. recursive: true,
  272. errorOnExist: true,
  273. force: false,
  274. verbatimSymlinks: true,
  275. })
  276. await rm(source, { recursive: true, force: true })
  277. }
  278. }
  279. /**
  280. * Everything a workshop uploaded, under every root: only when the workshop
  281. * itself is deleted. The folder is moved whole into that root's trash for
  282. * today, `<uploads>/.trash/<date>/<org>`, which is the same layout a single
  283. * released file lands in, so purgeTrash expires it TRASH_DAYS later like any
  284. * other. It used to be removed at once, and one call from a hijacked or
  285. * unattended session took every photo and document of the workshop beyond
  286. * reach of anything but the server's own backup.
  287. *
  288. * Earlier trash of the workshop is left where it is, for the same reason.
  289. */
  290. export async function removeOrganizationFiles(
  291. organizationId: string,
  292. { now = Date.now() }: { now?: number } = {}
  293. ): Promise<void> {
  294. const day = new Date(now).toISOString().slice(0, 10)
  295. for (const root of uploadsRoots()) {
  296. const dir = organizationDir(root, organizationId)
  297. if (!dir) return
  298. try {
  299. if (!(await taken(dir))) continue
  300. await moveInto(dir, path.join(path.resolve(root), TRASH, day, organizationId))
  301. } catch (err) {
  302. console.error(`[files] could not move the uploads of ${organizationId} to the trash:`, err)
  303. }
  304. }
  305. }
  306. /**
  307. * After a backup has written its files into one of the workshop's folders:
  308. * the files there that the backup did not bring back go through the same
  309. * release as any other, so one that a row still uses (uploaded after the
  310. * backup was taken, and not replaced by it) stays. The folder used to be
  311. * emptied before the restore, and took such files with it.
  312. */
  313. export async function releaseFilesNotRestored(
  314. organizationId: string,
  315. folder: string,
  316. restoredNames: ReadonlySet<string>
  317. ): Promise<ReleaseResult | null> {
  318. if (!FOLDERS.has(folder)) return null
  319. const dir = organizationDir(uploadsRoot(), organizationId)
  320. if (!dir) return null
  321. let entries: Dirent[]
  322. try {
  323. entries = await readdir(path.join(dir, folder), { withFileTypes: true })
  324. } catch {
  325. return null
  326. }
  327. const stale = entries
  328. .filter((entry) => entry.isFile() && !restoredNames.has(entry.name))
  329. .map((entry) => `/api/protected/files/${organizationId}/${folder}/${entry.name}`)
  330. return releaseFiles(stale, { organizationId, reason: `backup restored over ${folder}` })
  331. }
  332. export interface SweepResult {
  333. checked: number
  334. removed: number
  335. /** Set when the run stopped itself instead of removing anything. */
  336. refused?: string
  337. /** Workshops left alone this run, and why. */
  338. skipped: { organizationId: string; reason: string }[]
  339. /** Unused files left for the next run by the per-run limit. */
  340. deferred: number
  341. }
  342. /** The most files one run moves; a backlog is worked through over several nights. */
  343. const SWEEP_MAX_PER_RUN = 1000
  344. /**
  345. * A workshop where more than this share of the files looked at seem unused is
  346. * left alone, once there are a few: that is what a wrong or half-restored
  347. * database looks like, not a few abandoned uploads.
  348. */
  349. const SWEEP_MAX_SHARE = 0.5
  350. const SWEEP_MIN_FOR_SHARE = 20
  351. /**
  352. * Set to 1 to lift the share check for a run, after confirming by hand that
  353. * the database is the right one and the files really are unused (a workshop
  354. * that deleted most of its history before this manager existed).
  355. */
  356. const SWEEP_TRUST_DATABASE = 'FILE_SWEEP_TRUST_DATABASE'
  357. interface SweepCandidate {
  358. root: string
  359. file: StoredFile
  360. target: string
  361. mtimeMs: number
  362. }
  363. /**
  364. * Files no row points at, left behind by an upload whose second step never
  365. * happened (the page closed between writing the file and saving its row) or
  366. * by deletes before this manager existed. Moved to the trash like any other
  367. * release. Guarded against being pointed at the wrong database, which would
  368. * make every file look unused:
  369. *
  370. * - it looks only in the folders of workshops this database has, and not at
  371. * all if the database has none (an empty or half-restored database, or a
  372. * test server sharing the disk with another install);
  373. * - it leaves alone any workshop where more than half the files it looked at
  374. * seem unused, and says so in the log;
  375. * - it moves at most SWEEP_MAX_PER_RUN files a run, oldest first, and checks
  376. * those once more against the database right before moving them;
  377. * - only names the upload routes generate, only files older than `minAgeMs`
  378. * (a week, so a form left open with an upload in it is not caught), and only
  379. * through the same reference lists every other deletion uses.
  380. */
  381. export async function sweepOrphanFiles({
  382. now = Date.now(),
  383. minAgeMs = 7 * 24 * 60 * 60 * 1000,
  384. maxPerRun = SWEEP_MAX_PER_RUN,
  385. trustDatabase = process.env[SWEEP_TRUST_DATABASE] === '1',
  386. }: {
  387. now?: number
  388. minAgeMs?: number
  389. maxPerRun?: number
  390. trustDatabase?: boolean
  391. } = {}): Promise<SweepResult> {
  392. const result: SweepResult = { checked: 0, removed: 0, skipped: [], deferred: 0 }
  393. const refuse = (reason: string, err?: unknown) => {
  394. result.refused = reason
  395. console.error(`[files] sweep refused, nothing moved: ${reason}`, ...(err ? [err] : []))
  396. return result
  397. }
  398. if (!(await hasAnyOrganization())) return refuse('the database has no workshops')
  399. let used: Set<string>
  400. try {
  401. used = await allReferencedSuffixes()
  402. } catch (err) {
  403. return refuse('the database could not say which files are in use', err)
  404. }
  405. // Old enough, generated names, in known workshops' folders; per workshop.
  406. const perWorkshop = new Map<string, { checked: number; unused: SweepCandidate[] }>()
  407. for (const root of uploadsRoots()) {
  408. let dirs: Dirent[]
  409. try {
  410. dirs = await readdir(root, { withFileTypes: true })
  411. } catch {
  412. continue
  413. }
  414. const ids = dirs
  415. .filter((dir) => dir.isDirectory() && organizationDir(root, dir.name))
  416. .map((dir) => dir.name)
  417. const known = await existingOrganizationIds(ids)
  418. for (const organizationId of ids.filter((id) => known.has(id))) {
  419. const tally = perWorkshop.get(organizationId) ?? { checked: 0, unused: [] }
  420. perWorkshop.set(organizationId, tally)
  421. for (const folder of FOLDERS) {
  422. let entries: Dirent[]
  423. try {
  424. entries = await readdir(path.join(root, organizationId, folder), { withFileTypes: true })
  425. } catch {
  426. continue
  427. }
  428. for (const entry of entries) {
  429. if (!entry.isFile() || !GENERATED_NAME.test(entry.name)) continue
  430. const file = {
  431. organizationId,
  432. folder,
  433. name: entry.name,
  434. suffix: `/${folder}/${entry.name}`,
  435. }
  436. const target = pathUnder(root, file)
  437. if (!target) continue
  438. const stats = await lstat(target).catch(() => null)
  439. if (!stats?.isFile() || now - stats.mtimeMs < minAgeMs) continue
  440. tally.checked += 1
  441. if (!used.has(file.suffix)) {
  442. tally.unused.push({ root, file, target, mtimeMs: stats.mtimeMs })
  443. }
  444. }
  445. }
  446. }
  447. }
  448. const unused: SweepCandidate[] = []
  449. for (const [organizationId, { checked, unused: theirs }] of perWorkshop) {
  450. result.checked += checked
  451. const share = checked > 0 ? theirs.length / checked : 0
  452. if (!trustDatabase && checked >= SWEEP_MIN_FOR_SHARE && share > SWEEP_MAX_SHARE) {
  453. const reason = `${theirs.length} of ${checked} files look unused, more than a sweep should find; check the database, then run with ${SWEEP_TRUST_DATABASE}=1 if they really are`
  454. result.skipped.push({ organizationId, reason })
  455. console.error(`[files] sweep left ${organizationId} alone: ${reason}`)
  456. continue
  457. }
  458. unused.push(...theirs)
  459. }
  460. // Oldest first, a bounded number a night.
  461. unused.sort((a, b) => a.mtimeMs - b.mtimeMs)
  462. const chosen = unused.slice(0, maxPerRun)
  463. result.deferred = unused.length - chosen.length
  464. // The list of used files is from the start of the run: ask again for these.
  465. let stillUsed: Set<string>
  466. try {
  467. stillUsed = await referencedSuffixes([...new Set(chosen.map(({ file }) => file.suffix))])
  468. } catch (err) {
  469. return refuse('the database could not say which files are in use', err)
  470. }
  471. for (const { root, file, target } of chosen) {
  472. if (stillUsed.has(file.suffix)) continue
  473. if (await trashFile(root, target, file, now).catch(() => false)) {
  474. result.removed += 1
  475. console.warn(
  476. `[files] sweep: moved unused ${file.suffix} of ${file.organizationId} to the trash`
  477. )
  478. }
  479. }
  480. if (result.deferred > 0) {
  481. console.warn(`[files] sweep: ${result.deferred} more unused file(s) left for the next run`)
  482. }
  483. return result
  484. }
  485. /**
  486. * Deletes for good what has been in the trash longer than `keepDays`. Only
  487. * day folders inside each root's `.trash`, nothing else.
  488. */
  489. export async function purgeTrash({
  490. now = Date.now(),
  491. keepDays = TRASH_DAYS,
  492. }: {
  493. now?: number
  494. keepDays?: number
  495. } = {}): Promise<number> {
  496. let purged = 0
  497. const cutoff = new Date(now - keepDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
  498. for (const root of uploadsRoots()) {
  499. const trash = path.join(path.resolve(root), TRASH)
  500. let days: Dirent[]
  501. try {
  502. days = await readdir(trash, { withFileTypes: true })
  503. } catch {
  504. continue
  505. }
  506. for (const day of days) {
  507. if (!day.isDirectory() || !DAY_FOLDER.test(day.name) || day.name >= cutoff) continue
  508. await rm(path.join(trash, day.name), { recursive: true, force: true })
  509. purged += 1
  510. }
  511. }
  512. return purged
  513. }