assets.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { Model } from 'objection'
  2. import path from 'node:path'
  3. import fse from 'fs-extra'
  4. import { startsWith } from 'lodash-es'
  5. import { generateHash } from '../helpers/common.mjs'
  6. import { User } from './users.mjs'
  7. /**
  8. * Users model
  9. */
  10. export class Asset extends Model {
  11. static get tableName() { return 'assets' }
  12. static get jsonSchema () {
  13. return {
  14. type: 'object',
  15. properties: {
  16. id: {type: 'string'},
  17. filename: {type: 'string'},
  18. hash: {type: 'string'},
  19. ext: {type: 'string'},
  20. kind: {type: 'string'},
  21. mime: {type: 'string'},
  22. fileSize: {type: 'integer'},
  23. metadata: {type: 'object'},
  24. createdAt: {type: 'string'},
  25. updatedAt: {type: 'string'}
  26. }
  27. }
  28. }
  29. static get relationMappings() {
  30. return {
  31. author: {
  32. relation: Model.BelongsToOneRelation,
  33. modelClass: User,
  34. join: {
  35. from: 'assets.authorId',
  36. to: 'users.id'
  37. }
  38. }
  39. }
  40. }
  41. async $beforeUpdate(opt, context) {
  42. await super.$beforeUpdate(opt, context)
  43. this.updatedAt = new Date().toISOString()
  44. }
  45. async $beforeInsert(context) {
  46. await super.$beforeInsert(context)
  47. this.createdAt = new Date().toISOString()
  48. this.updatedAt = new Date().toISOString()
  49. }
  50. async getAssetPath() {
  51. let hierarchy = []
  52. if (this.folderId) {
  53. hierarchy = await WIKI.db.assetFolders.getHierarchy(this.folderId)
  54. }
  55. return (this.folderId) ? hierarchy.map(h => h.slug).join('/') + `/${this.filename}` : this.filename
  56. }
  57. async deleteAssetCache() {
  58. await fse.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${this.hash}.dat`))
  59. }
  60. static async upload(opts) {
  61. const fileInfo = path.parse(opts.originalname)
  62. // Check for existing asset
  63. let asset = await WIKI.db.assets.query().where({
  64. // hash: fileHash,
  65. folderId: opts.folderId
  66. }).first()
  67. // Build Object
  68. let assetRow = {
  69. filename: opts.originalname,
  70. ext: fileInfo.ext,
  71. kind: startsWith(opts.mimetype, 'image/') ? 'image' : 'binary',
  72. mime: opts.mimetype,
  73. fileSize: opts.size,
  74. folderId: opts.folderId
  75. }
  76. // Sanitize SVG contents
  77. if (
  78. WIKI.config.uploads.scanSVG &&
  79. (
  80. opts.mimetype.toLowerCase().startsWith('image/svg') ||
  81. fileInfo.ext.toLowerCase() === '.svg'
  82. )
  83. ) {
  84. const svgSanitizeJob = await WIKI.scheduler.registerJob({
  85. name: 'sanitize-svg',
  86. immediate: true,
  87. worker: true
  88. }, opts.path)
  89. await svgSanitizeJob.finished
  90. }
  91. // Save asset data
  92. try {
  93. const fileBuffer = await fse.readFile(opts.path)
  94. if (asset) {
  95. // Patch existing asset
  96. if (opts.mode === 'upload') {
  97. assetRow.authorId = opts.user.id
  98. }
  99. await WIKI.db.assets.query().patch(assetRow).findById(asset.id)
  100. await WIKI.db.knex('assetData').where({
  101. id: asset.id
  102. }).update({
  103. data: fileBuffer
  104. })
  105. } else {
  106. // Create asset entry
  107. assetRow.authorId = opts.user.id
  108. asset = await WIKI.db.assets.query().insert(assetRow)
  109. await WIKI.db.knex('assetData').insert({
  110. id: asset.id,
  111. data: fileBuffer
  112. })
  113. }
  114. // Move temp upload to cache
  115. // if (opts.mode === 'upload') {
  116. // await fs.move(opts.path, path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${fileHash}.dat`), { overwrite: true })
  117. // } else {
  118. // await fs.copy(opts.path, path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${fileHash}.dat`), { overwrite: true })
  119. // }
  120. // Add to Storage
  121. if (!opts.skipStorage) {
  122. await WIKI.db.storage.assetEvent({
  123. event: 'uploaded',
  124. asset: {
  125. ...asset,
  126. path: await asset.getAssetPath(),
  127. data: fileBuffer,
  128. authorId: opts.user.id,
  129. authorName: opts.user.name,
  130. authorEmail: opts.user.email
  131. }
  132. })
  133. }
  134. } catch (err) {
  135. WIKI.logger.warn(err)
  136. }
  137. }
  138. static async getThumbnail ({ id, path, locale, siteId }) {
  139. return WIKI.db.tree.query()
  140. .select('tree.*', 'assets.preview', 'assets.previewState')
  141. .innerJoin('assets', 'tree.id', 'assets.id')
  142. .where(id ? { 'tree.id': id } : {
  143. 'tree.hash': generateHash(path),
  144. 'tree.locale': locale,
  145. 'tree.siteId': siteId
  146. })
  147. .first()
  148. }
  149. static async getAsset({ pathArgs, siteId }, res) {
  150. try {
  151. const fileInfo = path.parse(pathArgs.path.toLowerCase())
  152. const fileHash = generateHash(pathArgs.path)
  153. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${siteId}/${fileHash}.dat`)
  154. // Force unsafe extensions to download
  155. if (WIKI.config.security.forceAssetDownload && !['.png', '.apng', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(fileInfo.ext)) {
  156. res.set('Content-disposition', 'attachment; filename=' + encodeURIComponent(fileInfo.base))
  157. }
  158. if (await WIKI.db.assets.getAssetFromCache({ cachePath, extName: fileInfo.ext }, res)) {
  159. return
  160. }
  161. // if (await WIKI.db.assets.getAssetFromStorage(assetPath, res)) {
  162. // return
  163. // }
  164. await WIKI.db.assets.getAssetFromDb({ pathArgs, fileHash, cachePath, siteId }, res)
  165. } catch (err) {
  166. if (err.code === `ECONNABORTED` || err.code === `EPIPE`) {
  167. return
  168. }
  169. WIKI.logger.error(err)
  170. res.sendStatus(500)
  171. }
  172. }
  173. static async getAssetFromCache({ cachePath, extName }, res) {
  174. try {
  175. await fse.access(cachePath, fse.constants.R_OK)
  176. } catch (err) {
  177. return false
  178. }
  179. res.type(extName)
  180. await new Promise(resolve => res.sendFile(cachePath, { dotfiles: 'deny' }, resolve))
  181. return true
  182. }
  183. static async getAssetFromStorage(assetPath, res) {
  184. const localLocations = await WIKI.db.storage.getLocalLocations({
  185. asset: {
  186. path: assetPath
  187. }
  188. })
  189. for (let location of localLocations.filter(location => Boolean(location.path))) {
  190. const assetExists = await WIKI.db.assets.getAssetFromCache(assetPath, location.path, res)
  191. if (assetExists) {
  192. return true
  193. }
  194. }
  195. return false
  196. }
  197. static async getAssetFromDb({ pathArgs, fileHash, cachePath, siteId }, res) {
  198. const asset = await WIKI.db.knex('tree').where({
  199. siteId,
  200. hash: fileHash
  201. }).first()
  202. if (asset) {
  203. const assetData = await WIKI.db.knex('assets').where('id', asset.id).first()
  204. res.type(assetData.fileExt)
  205. res.send(assetData.data)
  206. await fse.outputFile(cachePath, assetData.data)
  207. } else {
  208. res.sendStatus(404)
  209. }
  210. }
  211. static async flushTempUploads() {
  212. return fse.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `uploads`))
  213. }
  214. }