abstract-permanent-file-cache.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. import express from 'express'
  2. import { LRUCache } from 'lru-cache'
  3. import { Model } from 'sequelize'
  4. import { logger } from '@server/helpers/logger.js'
  5. import { CachePromise } from '@server/helpers/promise-cache.js'
  6. import { LRU_CACHE, STATIC_MAX_AGE } from '@server/initializers/constants.js'
  7. import { downloadImageFromWorker } from '@server/lib/worker/parent-process.js'
  8. import { HttpStatusCode } from '@peertube/peertube-models'
  9. type ImageModel = {
  10. fileUrl: string
  11. filename: string
  12. onDisk: boolean
  13. isOwned (): boolean
  14. getPath (): string
  15. save (): Promise<Model>
  16. }
  17. export abstract class AbstractPermanentFileCache <M extends ImageModel> {
  18. // Unsafe because it can return paths that do not exist anymore
  19. private readonly filenameToPathUnsafeCache = new LRUCache<string, string>({
  20. max: LRU_CACHE.FILENAME_TO_PATH_PERMANENT_FILE_CACHE.MAX_SIZE
  21. })
  22. protected abstract getImageSize (image: M): { width: number, height: number }
  23. protected abstract loadModel (filename: string): Promise<M>
  24. constructor (private readonly directory: string) {
  25. }
  26. async lazyServe (options: {
  27. filename: string
  28. res: express.Response
  29. next: express.NextFunction
  30. }) {
  31. const { filename, res, next } = options
  32. if (this.filenameToPathUnsafeCache.has(filename)) {
  33. return res.sendFile(this.filenameToPathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER })
  34. }
  35. const image = await this.lazyLoadIfNeeded(filename)
  36. if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
  37. const path = image.getPath()
  38. this.filenameToPathUnsafeCache.set(filename, path)
  39. return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
  40. if (!err) return
  41. this.onServeError({ err, image, next, filename })
  42. })
  43. }
  44. @CachePromise({
  45. keyBuilder: filename => filename
  46. })
  47. private async lazyLoadIfNeeded (filename: string) {
  48. const image = await this.loadModel(filename)
  49. if (!image) return undefined
  50. if (image.onDisk === false) {
  51. if (!image.fileUrl) return undefined
  52. try {
  53. await this.downloadRemoteFile(image)
  54. } catch (err) {
  55. logger.warn('Cannot process remote image %s.', image.fileUrl, { err })
  56. return undefined
  57. }
  58. }
  59. return image
  60. }
  61. async downloadRemoteFile (image: M) {
  62. logger.info('Download remote image %s lazily.', image.fileUrl)
  63. const destination = await this.downloadImage({
  64. filename: image.filename,
  65. fileUrl: image.fileUrl,
  66. size: this.getImageSize(image)
  67. })
  68. image.onDisk = true
  69. image.save()
  70. .catch(err => logger.error('Cannot save new image disk state.', { err }))
  71. return destination
  72. }
  73. private onServeError (options: {
  74. err: any
  75. image: M
  76. filename: string
  77. next: express.NextFunction
  78. }) {
  79. const { err, image, filename, next } = options
  80. // It seems this actor image is not on the disk anymore
  81. if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
  82. logger.error('Cannot lazy serve image %s.', filename, { err })
  83. this.filenameToPathUnsafeCache.delete(filename)
  84. image.onDisk = false
  85. image.save()
  86. .catch(err => logger.error('Cannot save new image disk state.', { err }))
  87. }
  88. return next(err)
  89. }
  90. private downloadImage (options: {
  91. fileUrl: string
  92. filename: string
  93. size: { width: number, height: number }
  94. }) {
  95. const downloaderOptions = {
  96. url: options.fileUrl,
  97. destDir: this.directory,
  98. destName: options.filename,
  99. size: options.size
  100. }
  101. return downloadImageFromWorker(downloaderOptions)
  102. }
  103. }