| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| 'use strict'; | ||
|
|
||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const fse = require('fs-extra'); | ||
| const crypto = require('crypto'); | ||
| const { fromStream } = require('file-type'); | ||
| const { nameToSlug } = require('@strapi/utils'); | ||
|
|
||
| /** | ||
| * Get the file type from a file stream. | ||
| * @param {File} file | ||
| * @returns {Promise<string>} | ||
| */ | ||
| const getFileType = async (file) => { | ||
| const fileType = await fromStream(file.getStream()); | ||
| return fileType?.ext; | ||
| }; | ||
|
|
||
| /** | ||
| * Generate a random file name based on the original file name. | ||
| */ | ||
| const generateFileName = (name) => { | ||
| const baseName = nameToSlug(name, { separator: '_', lowercase: false }); | ||
| const randomSuffix = crypto.randomBytes(5).toString('hex'); | ||
| return `${baseName}_${randomSuffix}`; | ||
| }; | ||
|
|
||
| /** | ||
| * Creates a temporary directory and deletes it after the callback is executed. | ||
| * @param {*} callback | ||
| * @returns | ||
| */ | ||
| async function withTempDirectory(callback) { | ||
| const folderPath = path.join(os.tmpdir(), 'strapi-upload-'); | ||
| const folder = await fse.mkdtemp(folderPath); | ||
|
|
||
| try { | ||
| const res = await callback(folder); | ||
| return res; | ||
| } finally { | ||
| await fse.remove(folder); | ||
| } | ||
| } | ||
|
|
||
| module.exports = { | ||
| getFileType, | ||
| generateFileName, | ||
| withTempDirectory, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| 'use strict'; | ||
|
|
||
| const { mapAsync, reduceAsync } = require('@strapi/utils'); | ||
|
|
||
| const MediaBuilder = () => { | ||
| const transformations = new Map(); | ||
|
|
||
| return { | ||
| transformOn(key, fileTypes, transforms) { | ||
| transformations.set(key, { fileTypes, transforms }); | ||
| return this; | ||
| }, | ||
|
|
||
| deleteTransform(key) { | ||
| transformations.delete(key); | ||
| return this; | ||
| }, | ||
|
|
||
| async transform(file) { | ||
| // Get all transformations for the given file extension | ||
| const _transformations = Array.from(transformations.values()) | ||
| .filter(({ fileTypes }) => fileTypes.includes(file.type)) | ||
| .flatMap(({ transforms }) => transforms); | ||
|
|
||
| return reduceAsync( | ||
| _transformations, | ||
| (files, transformation) => transformFiles(files, transformation), | ||
| [file] | ||
| ); | ||
| }, | ||
|
|
||
| groupByFormats(transformedFiles, srcFile) { | ||
| // Merge files into one | ||
| const file = transformedFiles.find((file) => !file.format) || srcFile; | ||
| const formattedFiles = transformedFiles.filter((file) => !!file.format); | ||
|
|
||
| // Add formatted files into the original file | ||
| file.formats = {}; | ||
| for (const formattedFile of formattedFiles) { | ||
| file.formats[formattedFile.format] = formattedFile; | ||
| } | ||
| return file; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| const transformFiles = async (files, transformation) => { | ||
| const transformedFiles = await mapAsync(files, transformation); | ||
| // Some transformations might return multiple files (e.g. resize) | ||
| return transformedFiles.flat(); | ||
| }; | ||
|
|
||
| module.exports = MediaBuilder; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
|
|
||
| const autoRotate = async (file) => { | ||
| return { | ||
| ...file, | ||
| getStream: () => file.getStream().pipe(sharp().rotate()), | ||
| }; | ||
| }; | ||
|
|
||
| module.exports = autoRotate; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
|
|
||
| const DEFAULT_BREAKPOINTS = { large: 1000, medium: 750, small: 500 }; | ||
| const getBreakpoints = () => strapi.config.get('plugin.upload.breakpoints', DEFAULT_BREAKPOINTS); | ||
|
|
||
| /** | ||
| * Resize image to fit within the specified dimensions, | ||
| * but only if the image is larger. If the image is smaller, it is not resized. | ||
| */ | ||
| const calculateInlineResizing = (size, { width, height }) => { | ||
| // No need to resize if the image is smaller | ||
| if (size > width && size > height) { | ||
| return undefined; | ||
| } | ||
|
|
||
| let newWidth = size; | ||
| let newHeight = size; | ||
|
|
||
| // Adjust the newWidth and height to maintain aspect ratio | ||
| if (width > height) { | ||
| newHeight = Math.round((height / width) * size); | ||
| } else { | ||
| newWidth = Math.round((width / height) * size); | ||
| } | ||
|
|
||
| return { width: newWidth, height: newHeight }; | ||
| }; | ||
|
|
||
| const breakpoints = async (file) => { | ||
| // Only resize original image and not other responsive formats (e.g thumbnail) | ||
| if (file.format) { | ||
| return [file]; | ||
| } | ||
|
|
||
| const breakpoints = getBreakpoints(); | ||
| const files = [file]; | ||
|
|
||
| for (const [format, breakpoint] of Object.entries(breakpoints)) { | ||
| const resize = calculateInlineResizing(breakpoint, file); | ||
|
|
||
| if (resize) { | ||
| files.push({ | ||
| ...file, | ||
| name: `${format}_${file.name}`, | ||
| hash: `${format}_${file.hash}`, | ||
| format, | ||
| width: resize.width, | ||
| height: resize.height, | ||
| getStream: () => file.getStream().pipe(sharp().resize(resize.width, resize.height)), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return files; | ||
| }; | ||
|
|
||
| module.exports = breakpoints; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
| const autoRotate = require('./auto-rotate'); | ||
| const breakpoints = require('./breakpoints'); | ||
| const metadata = require('./metadata'); | ||
| const optimize = require('./optimize'); | ||
| const thumbnail = require('./thumbnail'); | ||
|
|
||
| // set all necessary sharp options here to reduce ram usage | ||
| sharp.concurrency(1); | ||
| sharp.cache(false); | ||
| // set VIPS_DISC_THRESHOLD to 50 megs | ||
| // VIPS_DISC_THRESHOLD=50m | ||
|
|
||
| module.exports = { | ||
| autoRotate, | ||
| breakpoints, | ||
| metadata, | ||
| optimize, | ||
| thumbnail, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
|
|
||
| const getMetadata = (file) => | ||
| new Promise((resolve, reject) => { | ||
| const pipeline = sharp(); | ||
| pipeline.metadata().then(resolve).catch(reject); | ||
| file.getStream().pipe(pipeline); | ||
| }); | ||
|
|
||
| const metadata = async (file) => { | ||
| const metadata = await getMetadata(file); | ||
|
|
||
| return { | ||
| ...file, | ||
| width: metadata.width, | ||
| height: metadata.height, | ||
| }; | ||
| }; | ||
|
|
||
| module.exports = metadata; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
|
|
||
| const optimize = async (file) => { | ||
| return { | ||
| ...file, | ||
| getStream() { | ||
| const stream = file.getStream(); | ||
| if (sharp()[file?.type]) { | ||
| stream.pipe(sharp()[file.type]({ quality: 80 })); | ||
| } | ||
| return stream; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| module.exports = optimize; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| 'use strict'; | ||
|
|
||
| const sharp = require('sharp'); | ||
|
|
||
| const THUMBNAIL_SIZE = { width: 245, height: 156 }; | ||
|
|
||
| const calculateInsideResizing = (srcSize, destSize) => { | ||
| // No need to resize if the image is smaller than the thumbnail size | ||
| if (srcSize.width < destSize.width && srcSize.height < destSize.height) { | ||
| return; | ||
| } | ||
|
|
||
| const srcAspectRatio = srcSize.width / srcSize.height; | ||
| const destAspectRatio = destSize.width / destSize.height; | ||
|
|
||
| let width = destSize.width; | ||
| let height = destSize.height; | ||
|
|
||
| if (srcAspectRatio > destAspectRatio) { | ||
| height = Math.round(destSize.width / srcAspectRatio); | ||
| } else { | ||
| width = Math.round(destSize.height * srcAspectRatio); | ||
| } | ||
|
|
||
| return { width, height }; | ||
| }; | ||
|
|
||
| const thumbnail = async (file) => { | ||
| // Only resize original image and not other responsive formats (e.g breakpoints) | ||
| if (file.format) { | ||
| return [file]; | ||
| } | ||
|
|
||
| const files = [file]; | ||
|
|
||
| const resize = calculateInsideResizing(file, THUMBNAIL_SIZE); | ||
|
|
||
| if (resize) { | ||
| files.push({ | ||
| ...file, | ||
| name: `thumbnail_${file.name}`, | ||
| hash: `thumbnail_${file.hash}`, | ||
| format: 'thumbnail', | ||
| width: resize.width, | ||
| height: resize.height, | ||
| getStream: () => file.getStream().pipe(sharp().resize(resize.width, resize.height)), | ||
| }); | ||
| } | ||
|
|
||
| return files; | ||
| }; | ||
|
|
||
| module.exports = thumbnail; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| 'use strict'; | ||
|
|
||
| const { ThrottleGroup } = require('stream-throttle'); | ||
|
|
||
| const THROTTLE_MB_MULTIPLIER = 1024 * 1024; | ||
|
|
||
| const tg = new ThrottleGroup({ | ||
| // Rate in bytes per second e.g 10240 = 10KB/s. | ||
| rate: 100 * THROTTLE_MB_MULTIPLIER, // 100 MB/s | ||
| }); | ||
|
|
||
| const throttle = async (file) => { | ||
| return { | ||
| ...file, | ||
| getStream: () => file.getStream().pipe(tg.throttle()), | ||
| }; | ||
| }; | ||
|
|
||
| module.exports = throttle; |