diff --git a/.prettierrc b/.prettierrc index 217337e..6417e42 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": true, - "tabWidth": 2 + "tabWidth": 2, + "printWidth": 120 } diff --git a/package-lock.json b/package-lock.json index 3f9b3b7..5d01916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", "@types/rimraf": "^3.0.2", + "@types/sharp": "^0.29.5", "@types/unzipper": "^0.10.5", "@types/uuid": "^8.3.4", "@types/webpack-env": "^1.16.3", @@ -150,6 +151,9 @@ "typescript": "^4.5.5", "typescript-transform-paths": "^3.3.1" }, + "optionalDependencies": { + "sharp": "^0.30.0" + }, "peerDependencies": { "path-to-regexp": "^6.2.0" } @@ -3266,6 +3270,15 @@ "@types/node": "*" } }, + "node_modules/@types/sharp": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.29.5.tgz", + "integrity": "sha512-3TC+S3H5RwnJmLYMHrcdfNjz/CaApKmujjY9b6PU/pE6n0qfooi99YqXGWoW8frU9EWYj/XTI35Pzxa+ThAZ5Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unzipper": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.5.tgz", @@ -4923,10 +4936,9 @@ "license": "MIT" }, "node_modules/color-string": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz", - "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==", - "license": "MIT", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz", + "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==", "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -5603,6 +5615,8 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -10395,11 +10409,11 @@ } }, "node_modules/prebuild-install": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.0.tgz", - "integrity": "sha512-IvSenf33K7JcgddNz2D5w521EgO+4aMMjFt73Uk9FRzQ7P+QZPKrp7qPsDydsSwjGt3T5xRNnM1bj1zMTD5fTA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.1.tgz", + "integrity": "sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg==", "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", @@ -10420,6 +10434,14 @@ "node": ">=10" } }, + "node_modules/prebuild-install/node_modules/detect-libc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.0.tgz", + "integrity": "sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw==", + "engines": { + "node": ">=8" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11465,6 +11487,54 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.30.0.tgz", + "integrity": "sha512-L3m/l6yQFr3oGBUzcSAlN/R9yGFPYqM9FpMUe6Z4nHg4sWtP3hW1rcz+aaHklhD4wX5Jqh5PY9z+A1d4Qt3Hfg==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "color": "^4.2.0", + "detect-libc": "^2.0.0", + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1", + "semver": "^7.3.5", + "simple-get": "^4.0.1", + "tar-fs": "^2.1.1", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=12.13.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.0.tgz", + "integrity": "sha512-hHTcrbvEnGjC7WBMk6ibQWFVDgEFTVmjrz2Q5HlU6ltwxv0JJN2Z8I7uRbWeQLF04dikxs8zgyZkazRJvSMtyQ==", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + } + }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.0.tgz", + "integrity": "sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sharp/node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "optional": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11532,9 +11602,9 @@ ] }, "node_modules/simple-get": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.0.tgz", - "integrity": "sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", "funding": [ { "type": "github", @@ -15774,6 +15844,15 @@ "@types/node": "*" } }, + "@types/sharp": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.29.5.tgz", + "integrity": "sha512-3TC+S3H5RwnJmLYMHrcdfNjz/CaApKmujjY9b6PU/pE6n0qfooi99YqXGWoW8frU9EWYj/XTI35Pzxa+ThAZ5Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/unzipper": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.5.tgz", @@ -17015,9 +17094,9 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "color-string": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz", - "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz", + "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==", "requires": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -17484,7 +17563,9 @@ "detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "optional": true, + "peer": true }, "dicer": { "version": "0.2.5", @@ -20995,11 +21076,11 @@ } }, "prebuild-install": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.0.tgz", - "integrity": "sha512-IvSenf33K7JcgddNz2D5w521EgO+4aMMjFt73Uk9FRzQ7P+QZPKrp7qPsDydsSwjGt3T5xRNnM1bj1zMTD5fTA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.1.tgz", + "integrity": "sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg==", "requires": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", @@ -21012,6 +21093,13 @@ "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "detect-libc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.0.tgz", + "integrity": "sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw==" + } } }, "prelude-ls": { @@ -21774,6 +21862,46 @@ "safe-buffer": "^5.0.1" } }, + "sharp": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.30.0.tgz", + "integrity": "sha512-L3m/l6yQFr3oGBUzcSAlN/R9yGFPYqM9FpMUe6Z4nHg4sWtP3hW1rcz+aaHklhD4wX5Jqh5PY9z+A1d4Qt3Hfg==", + "optional": true, + "requires": { + "color": "^4.2.0", + "detect-libc": "^2.0.0", + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1", + "semver": "^7.3.5", + "simple-get": "^4.0.1", + "tar-fs": "^2.1.1", + "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "color": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.0.tgz", + "integrity": "sha512-hHTcrbvEnGjC7WBMk6ibQWFVDgEFTVmjrz2Q5HlU6ltwxv0JJN2Z8I7uRbWeQLF04dikxs8zgyZkazRJvSMtyQ==", + "optional": true, + "requires": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + } + }, + "detect-libc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.0.tgz", + "integrity": "sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw==", + "optional": true + }, + "node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "optional": true + } + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -21814,9 +21942,9 @@ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" }, "simple-get": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.0.tgz", - "integrity": "sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", "requires": { "decompress-response": "^6.0.0", "once": "^1.3.1", diff --git a/package.json b/package.json index 8423a2a..4bada05 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", "@types/rimraf": "^3.0.2", + "@types/sharp": "^0.29.5", "@types/unzipper": "^0.10.5", "@types/uuid": "^8.3.4", "@types/webpack-env": "^1.16.3", @@ -177,6 +178,9 @@ "peerDependencies": { "path-to-regexp": "^6.2.0" }, + "optionalDependencies": { + "sharp": "^0.30.0" + }, "node": ">=12.x.x <=16.x.x", "npm": ">=6.0.0", "keywords": [ diff --git a/src/server/common/module-helper.ts b/src/server/common/module-helper.ts new file mode 100644 index 0000000..877d367 --- /dev/null +++ b/src/server/common/module-helper.ts @@ -0,0 +1,3 @@ +export const moduleExists = (module: string): boolean => { + return !!__webpack_modules__?.[module]; +}; \ No newline at end of file diff --git a/src/server/controllers/image.controller.ts b/src/server/controllers/image.controller.ts new file mode 100644 index 0000000..9a3af14 --- /dev/null +++ b/src/server/controllers/image.controller.ts @@ -0,0 +1,114 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import sharp, { AvailableFormatInfo, FitEnum, FormatEnum } from 'sharp'; +import express from 'express'; +import asyncMiddleware from '@server/middleware/async.middleware'; +import { getEnhancedRepository } from '@server/common/orm-helpers'; +import Asset from '@server/models/asset.model'; +import NotFoundError from '@server/errors/not-found-error'; +import BadRequestError from '@server/errors/bad-request-error'; +import queryString from 'query-string'; + +const imageController = express(); + +type ImageFormat = keyof FormatEnum | AvailableFormatInfo; + +interface ImageOptions { + // General + animated?: boolean; + progressive?: boolean; + format?: ImageFormat | 'auto'; + quality?: number; + // Resizing + width?: number; + height?: number; + fit?: keyof FitEnum; + gravity?: string; +} + +const isMimeSupported = (mimeType: string) => /^image\//i.test(mimeType); + +const resolveFormat = (accepts: string, mimeType: string): ImageFormat => { + switch (true) { + case accepts.includes('image/avif'): + return 'avif'; + case accepts.includes('image/webp'): + return 'webp'; + case !['image/jpeg', 'image/jpg'].includes(mimeType): + return 'png'; + default: + return 'jpg'; + } +} + +imageController.get( + '/image/*', + asyncMiddleware(async (req, res) => { + const npath = req.params[0]; + const query = queryString.parseUrl(req.url, { + parseBooleans: true, + parseNumbers: true + }).query as ImageOptions; + + const accepts = req.header('Accept') || ''; + + const { + animated = false, + progressive = true, + quality = 90, + fit = 'cover', + gravity = 'center', + width, + height, + format: defaultFormat = 'auto' + } = query + + const assetRepository = getEnhancedRepository(Asset); + const asset = await assetRepository.findOne({ where: { npath } }); + + if (!asset) { + throw new NotFoundError('asset_not_found'); + } + + if (!isMimeSupported(asset.mimeType)) { + throw new BadRequestError('type_not_supported', { + mimeType: asset.mimeType + }); + } + + let format: ImageFormat; + + if (!defaultFormat || defaultFormat === 'auto') { + format = resolveFormat(accepts, asset.mimeType); + } else { + format = undefined; + } + + let imageProcessor = sharp({ animated }); + + imageProcessor = imageProcessor.toFormat(format, { + progressive, + quality + }); + + if (width || height) { + imageProcessor = imageProcessor.resize({ + withoutEnlargement: true, + fit, + position: gravity, + width, + height, + }); + } + + const assetReadStream = asset.readStream(); + + res.writeHead(200, { + 'Content-Type': `image/${format}`, + 'Cache-Control': process.env.ASSETS_CACHE_CONTROL || 'no-cache' + }); + + assetReadStream.pipe(imageProcessor).pipe(res); + }) +); + +export default imageController; diff --git a/src/server/listeners/controllers.listener.ts b/src/server/listeners/controllers.listener.ts index b81e7fd..62c9012 100644 --- a/src/server/listeners/controllers.listener.ts +++ b/src/server/listeners/controllers.listener.ts @@ -14,6 +14,8 @@ import publicController from '@server/controllers/public.controller'; import backupController from '@server/controllers/backup.controller'; import accessTokenController from '@server/controllers/access-token.controller'; import searchController from '@server/controllers/search.controller'; +import { moduleExists } from '@server/common/module-helper'; +import logger from '@shared/features/logger'; Hooks.addAction( 'api/init', @@ -46,6 +48,13 @@ Hooks.addAction( app.use(backupController); app.use(accessTokenController); app.use(searchController); + + if (moduleExists('sharp')) { + const { default: imageController } = await import('@server/controllers/image.controller'); + app.use(imageController); + } else { + logger.info('Image module disabled. Sharp dependency not found.') + } }, { id: 'core/controllers' } ); diff --git a/src/server/models/asset.model.ts b/src/server/models/asset.model.ts index 3521d39..a7bfe6c 100644 --- a/src/server/models/asset.model.ts +++ b/src/server/models/asset.model.ts @@ -19,13 +19,14 @@ import { Tree, TreeChildren, TreeParent, - Unique, UpdateDateColumn, } from 'typeorm'; import AssetMeta from '@server/models/asset-meta.model'; import { IAsset } from '@shared/interfaces/model'; import User from './user.model'; import Tag from './tag.model'; +import FileDriver from '@server/drivers/file.driver'; +import { ReadStream } from 'fs'; @Entity() @Tree('materialized-path') @@ -99,4 +100,9 @@ export default class Asset extends BaseEntity implements IAsset { } return result; } + + public readStream(options?: any): ReadStream { + const fileDriver = FileDriver.getInstance(); + return fileDriver.createReadStream(this.document, options || {}); + } }