asset.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import _ from 'lodash-es'
  2. import sanitize from 'sanitize-filename'
  3. import { generateError, generateSuccess } from '../../helpers/graph.mjs'
  4. import { decodeTreePath, generateHash } from '../../helpers/common.mjs'
  5. import path from 'node:path'
  6. import fs from 'fs-extra'
  7. import { v4 as uuid } from 'uuid'
  8. import { pipeline } from 'node:stream/promises'
  9. export default {
  10. Query: {
  11. async assetById (obj, args, context) {
  12. const asset = await WIKI.db.assets.query().findById(args.id).withGraphFetched('tree')
  13. if (asset) {
  14. const assetPath = asset.tree.folderPath ? `${decodeTreePath(asset.tree.folderPath)}/${asset.tree.fileName}` : asset.tree.fileName
  15. if (!WIKI.auth.checkAccess(context.req.user, ['read:assets'], { path: assetPath })) {
  16. throw new Error('ERR_FORBIDDEN')
  17. }
  18. return asset
  19. } else {
  20. throw new Error('ERR_ASSET_NOT_FOUND')
  21. }
  22. }
  23. },
  24. Mutation: {
  25. /**
  26. * Rename an Asset
  27. */
  28. async renameAsset (obj, args, context) {
  29. try {
  30. const filename = sanitize(args.fileName).toLowerCase()
  31. const asset = await WIKI.db.assets.query().findById(args.id)
  32. const treeItem = await WIKI.db.tree.query().findById(args.id)
  33. if (asset && treeItem) {
  34. // Check for extension mismatch
  35. if (!_.endsWith(filename, asset.fileExt)) {
  36. throw new Error('ERR_ASSET_EXT_MISMATCH')
  37. }
  38. // Check for non-dot files changing to dotfile
  39. if (asset.fileExt.length > 0 && filename.length - asset.fileExt.length < 1) {
  40. throw new Error('ERR_ASSET_INVALID_DOTFILE')
  41. }
  42. // Check for collision
  43. const assetCollision = await WIKI.db.tree.query().where({
  44. folderPath: treeItem.folderPath,
  45. fileName: filename
  46. }).first()
  47. if (assetCollision) {
  48. throw new Error('ERR_ASSET_ALREADY_EXISTS')
  49. }
  50. // Check source asset permissions
  51. const assetSourcePath = (treeItem.folderPath) ? decodeTreePath(treeItem.folderPath) + `/${treeItem.fileName}` : treeItem.fileName
  52. if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetSourcePath })) {
  53. throw new Error('ERR_FORBIDDEN')
  54. }
  55. // Check target asset permissions
  56. const assetTargetPath = (treeItem.folderPath) ? decodeTreePath(treeItem.folderPath) + `/${filename}` : filename
  57. if (!WIKI.auth.checkAccess(context.req.user, ['write:assets'], { path: assetTargetPath })) {
  58. throw new Error('ERR_TARGET_FORBIDDEN')
  59. }
  60. // Update filename + hash
  61. const itemHash = generateHash(assetTargetPath)
  62. await WIKI.db.assets.query().patch({
  63. fileName: filename
  64. }).findById(asset.id)
  65. await WIKI.db.tree.query().patch({
  66. fileName: filename,
  67. title: filename,
  68. hash: itemHash
  69. }).findById(treeItem.id)
  70. // TODO: Delete old asset cache
  71. WIKI.events.outbound.emit('purgeItemCache', itemHash)
  72. // TODO: Rename in Storage
  73. // await WIKI.db.storage.assetEvent({
  74. // event: 'renamed',
  75. // asset: {
  76. // ...asset,
  77. // path: assetSourcePath,
  78. // destinationPath: assetTargetPath,
  79. // moveAuthorId: context.req.user.id,
  80. // moveAuthorName: context.req.user.name,
  81. // moveAuthorEmail: context.req.user.email
  82. // }
  83. // })
  84. return {
  85. operation: generateSuccess('Asset has been renamed successfully.')
  86. }
  87. } else {
  88. throw new Error('ERR_INVALID_ASSET')
  89. }
  90. } catch (err) {
  91. return generateError(err)
  92. }
  93. },
  94. /**
  95. * Delete an Asset
  96. */
  97. async deleteAsset (obj, args, context) {
  98. try {
  99. const treeItem = await WIKI.db.tree.query().findById(args.id)
  100. if (treeItem) {
  101. // Check permissions
  102. const assetPath = (treeItem.folderPath) ? decodeTreePath(treeItem.folderPath) + `/${treeItem.fileName}` : treeItem.fileName
  103. if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetPath })) {
  104. throw new Error('ERR_FORBIDDEN')
  105. }
  106. // Delete from DB
  107. await WIKI.db.assets.query().deleteById(treeItem.id)
  108. await WIKI.db.tree.query().deleteById(treeItem.id)
  109. // TODO: Delete asset cache
  110. WIKI.events.outbound.emit('purgeItemCache', treeItem.hash)
  111. // TODO: Delete from Storage
  112. // await WIKI.db.storage.assetEvent({
  113. // event: 'deleted',
  114. // asset: {
  115. // ...asset,
  116. // path: assetPath,
  117. // authorId: context.req.user.id,
  118. // authorName: context.req.user.name,
  119. // authorEmail: context.req.user.email
  120. // }
  121. // })
  122. return {
  123. operation: generateSuccess('Asset has been deleted successfully.')
  124. }
  125. } else {
  126. throw new Error('ERR_INVALID_ASSET')
  127. }
  128. } catch (err) {
  129. return generateError(err)
  130. }
  131. },
  132. /**
  133. * Upload Assets
  134. */
  135. async uploadAssets (obj, args, context) {
  136. try {
  137. // FIXME: Perm
  138. // -> Get Folder
  139. let folder = {}
  140. if (args.folderId || args.folderPath) {
  141. // Get Folder by ID
  142. folder = await WIKI.db.tree.getFolder({ id: args.folderId })
  143. if (!folder) {
  144. throw new Error('ERR_INVALID_FOLDER_ID')
  145. }
  146. } else if (args.folderPath) {
  147. // Get Folder by Path
  148. if (!args.locale) {
  149. throw new Error('ERR_MISSING_LOCALE')
  150. } else if (!args.siteId) {
  151. throw new Error('ERR_MISSING_SITE_ID')
  152. }
  153. folder = await WIKI.db.tree.getFolder({
  154. path: args.folderPath,
  155. locale: args.locale,
  156. siteId: args.siteId,
  157. createIfMissing: true
  158. })
  159. if (!folder) {
  160. throw new Error('ERR_INVALID_FOLDER_PATH')
  161. }
  162. } else {
  163. // Use Root Folder
  164. if (!args.locale) {
  165. throw new Error('ERR_MISSING_LOCALE')
  166. } else if (!args.siteId) {
  167. throw new Error('ERR_MISSING_SITE_ID')
  168. }
  169. folder = {
  170. folderPath: '',
  171. fileName: '',
  172. locale: args.locale,
  173. siteId: args.siteId
  174. }
  175. }
  176. // -> Get Site
  177. const site = await WIKI.db.sites.query().findById(folder.siteId)
  178. if (!site) {
  179. throw new Error('ERR_INVALID_SITE_ID')
  180. }
  181. // -> Get Storage Targets
  182. const storageTargets = await WIKI.db.storage.getTargets({ siteId: folder.siteId, enabledOnly: true })
  183. // -> Process Assets
  184. const results = await Promise.allSettled(args.files.map(async fl => {
  185. const { filename, mimetype, createReadStream } = await fl
  186. const sanitizedFilename = sanitize(filename).toLowerCase().trim()
  187. WIKI.logger.debug(`Processing asset upload ${sanitizedFilename} of type ${mimetype}...`)
  188. // Parse file extension
  189. if (sanitizedFilename.indexOf('.') <= 0) {
  190. throw new Error('ERR_ASSET_DOTFILE_NOTALLOWED')
  191. }
  192. const fileExt = _.last(sanitizedFilename.split('.')).toLowerCase()
  193. // Determine asset kind
  194. let fileKind = 'other'
  195. switch (fileExt) {
  196. case 'jpg':
  197. case 'jpeg':
  198. case 'png':
  199. case 'webp':
  200. case 'gif':
  201. case 'tiff':
  202. case 'svg':
  203. fileKind = 'image'
  204. break
  205. case 'pdf':
  206. case 'docx':
  207. case 'xlsx':
  208. case 'pptx':
  209. case 'odt':
  210. case 'epub':
  211. case 'csv':
  212. case 'md':
  213. case 'txt':
  214. case 'adoc':
  215. case 'rtf':
  216. case 'wdp':
  217. case 'xps':
  218. case 'ods':
  219. fileKind = 'document'
  220. break
  221. }
  222. // Save to temp disk
  223. const tempFileId = uuid()
  224. const tempFilePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `uploads/${tempFileId}.dat`)
  225. WIKI.logger.debug(`Writing asset upload ${sanitizedFilename} to temp disk...`)
  226. await pipeline(
  227. createReadStream(),
  228. fs.createWriteStream(tempFilePath)
  229. )
  230. WIKI.logger.debug(`Querying asset ${sanitizedFilename} file size...`)
  231. const tempFileStat = await fs.stat(tempFilePath)
  232. // Format filename
  233. const formattedFilename = site.config.uploads.normalizeFilename ? sanitizedFilename.replaceAll(' ', '-') : sanitizedFilename
  234. // Save asset to DB
  235. WIKI.logger.debug(`Saving asset ${sanitizedFilename} metadata to DB...`)
  236. const assetRaw = await WIKI.db.knex('assets').insert({
  237. fileName: formattedFilename,
  238. fileExt,
  239. kind: fileKind,
  240. mimeType: mimetype,
  241. fileSize: Math.round(tempFileStat.size),
  242. meta: {},
  243. previewState: fileKind === 'image' ? 'pending' : 'none',
  244. authorId: context.req.user.id,
  245. siteId: folder.siteId
  246. }).returning('*')
  247. const asset = assetRaw[0]
  248. // Add to tree
  249. await WIKI.db.tree.addAsset({
  250. id: asset.id,
  251. parentPath: folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName,
  252. fileName: formattedFilename,
  253. title: formattedFilename,
  254. locale: folder.locale,
  255. siteId: folder.siteId,
  256. meta: {
  257. authorId: asset.authorId,
  258. creatorId: asset.creatorId,
  259. fileSize: asset.fileSize,
  260. fileExt,
  261. mimeType: mimetype,
  262. ownerId: asset.ownerId
  263. }
  264. })
  265. // Save to storage targets
  266. const storageInfo = {}
  267. const failedStorage = []
  268. await Promise.allSettled(storageTargets.map(async storageTarget => {
  269. WIKI.logger.debug(`Saving asset ${sanitizedFilename} to storage target ${storageTarget.module} (${storageTarget.id})...`)
  270. try {
  271. const strInfo = await WIKI.storage.modules[storageTarget.module].assetUploaded({
  272. asset,
  273. createReadStream,
  274. storageTarget,
  275. tempFilePath
  276. })
  277. storageInfo[storageTarget.id] = strInfo ?? true
  278. } catch (err) {
  279. WIKI.logger.warn(`Failed to save asset ${sanitizedFilename} to storage target ${storageTarget.module} (${storageTarget.id}):`)
  280. WIKI.logger.warn(err)
  281. failedStorage.push({
  282. storageId: storageTarget.id,
  283. storageModule: storageTarget.module,
  284. fileId: asset.id,
  285. fileName: formattedFilename
  286. })
  287. }
  288. }))
  289. // Save Storage Info to DB
  290. await WIKI.db.knex('assets').where({ id: asset.id }).update({ storageInfo })
  291. // Create thumbnail
  292. if (fileKind === 'image') {
  293. if (!WIKI.extensions.ext.sharp.isInstalled) {
  294. WIKI.logger.warn('Cannot generate asset thumbnail because the Sharp extension is not installed.')
  295. } else {
  296. WIKI.logger.debug(`Generating thumbnail of asset ${sanitizedFilename}...`)
  297. const previewDestPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `uploads/${tempFileId}-thumb.webp`)
  298. // -> Resize
  299. await WIKI.extensions.ext.sharp.resize({
  300. format: 'webp',
  301. inputStream: createReadStream(),
  302. outputPath: previewDestPath,
  303. width: 320,
  304. height: 200,
  305. fit: 'inside'
  306. })
  307. // -> Save to DB
  308. await WIKI.db.knex('assets').where({
  309. id: asset.id
  310. }).update({
  311. preview: await fs.readFile(previewDestPath),
  312. previewState: 'ready'
  313. })
  314. // -> Delete
  315. await fs.remove(previewDestPath)
  316. }
  317. }
  318. WIKI.logger.debug(`Removing asset ${sanitizedFilename} temp file...`)
  319. await fs.remove(tempFilePath)
  320. WIKI.logger.debug(`Processed asset ${sanitizedFilename} successfully.`)
  321. return failedStorage
  322. }))
  323. // Return results
  324. const failedResults = results.filter(r => r.status === 'rejected')
  325. if (failedResults.length > 0) {
  326. // -> One or more thrown errors
  327. WIKI.logger.warn('Failed to upload one or more assets:')
  328. for (const failedResult of failedResults) {
  329. WIKI.logger.warn(failedResult.reason)
  330. }
  331. throw new Error('ERR_UPLOAD_FAILED')
  332. } else {
  333. const failedSaveTargets = results.map(r => r.value).filter(r => r.length > 0)
  334. if (failedSaveTargets.length > 0) {
  335. // -> One or more storage target save errors
  336. WIKI.logger.warn('Failed to save one or more assets to storage targets.')
  337. throw new Error('ERR_UPLOAD_TARGET_FAILED')
  338. } else {
  339. WIKI.logger.debug('Asset(s) uploaded successfully.')
  340. return {
  341. operation: generateSuccess('Asset(s) uploaded successfully')
  342. }
  343. }
  344. }
  345. } catch (err) {
  346. WIKI.logger.warn(err)
  347. return generateError(err)
  348. }
  349. },
  350. /**
  351. * Flush Temporary Uploads
  352. */
  353. async flushTempUploads (obj, args, context) {
  354. try {
  355. if (!WIKI.auth.checkAccess(context.req.user, ['manage:system'])) {
  356. throw new Error('ERR_FORBIDDEN')
  357. }
  358. await WIKI.db.assets.flushTempUploads()
  359. return {
  360. operation: generateSuccess('Temporary Uploads have been flushed successfully.')
  361. }
  362. } catch (err) {
  363. return generateError(err)
  364. }
  365. }
  366. }
  367. }