├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── nodejs.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc ├── .npmignore ├── .npmrc ├── .releaserc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __mocks__ ├── fs.ts └── got.ts ├── __tests__ ├── cors.ts ├── express-sharp.ts └── images │ ├── a.jpg │ ├── b.png │ ├── foo-_A.jpg │ └── zade4np6qh-bardokat-designs-makramee-schlusselanhanger-noa-hellgrau.jpg ├── commitlint.config.js ├── docs └── express-sharp.gif ├── example ├── app.ts ├── images │ └── 1.jpeg ├── public │ ├── example.mjs │ ├── image.mjs │ ├── main.css │ └── utils.mjs └── views │ ├── index.pug │ └── layout.pug ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── src ├── adapter │ ├── fs.adapter.test.ts │ ├── fs.adapter.ts │ ├── http.adapter.test.ts │ ├── http.adapter.ts │ ├── index.ts │ ├── s3.adapter.test.ts │ └── s3.adapter.ts ├── cached-image.test.ts ├── cached-image.ts ├── config.service.test.ts ├── config.service.ts ├── decorators.test.ts ├── decorators.ts ├── express-sharp-client.test.ts ├── express-sharp-client.ts ├── http-exception.test.ts ├── http-exception.ts ├── image-url.service.test.ts ├── image-url.service.ts ├── index.ts ├── interfaces.ts ├── logger.ts ├── middleware │ ├── etag-caching.middleware.test.ts │ ├── etag-caching.middleware.ts │ ├── express-sharp.middleware.test.ts │ ├── express-sharp.middleware.ts │ ├── signed-url.middleware.test.ts │ ├── signed-url.middleware.ts │ ├── transform-image.middleware.test.ts │ ├── transform-image.middleware.ts │ ├── transform-query-params.middleware.ts │ ├── use-webp-if-supported.middleware.test.ts │ ├── use-webp-if-supported.middleware.ts │ ├── validator.middleware.test.ts │ └── validator.middleware.ts ├── object-hash.service.test.ts ├── object-hash.service.ts ├── optional-require.ts ├── resize.dto.ts ├── signed-url.service.test.ts ├── signed-url.service.ts ├── transformer.service.test.ts ├── transformer.service.ts ├── util.test.ts ├── util.ts └── validator │ ├── is-url.test.ts │ └── is-url.ts ├── test └── setup.ts ├── tsconfig.build.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.eslintrc.js 2 | coverage 3 | dist 4 | test/setup.ts 5 | *.js 6 | *.mjs 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@heise'], 4 | plugins: ['sort-keys-fix', 'eslint-plugin-no-only-tests'], 5 | rules: { 6 | 'sort-keys-fix/sort-keys-fix': 'error', 7 | 'no-prototype-builtins': 'off', 8 | 'no-unused-vars': 'off', 9 | 'security/detect-non-literal-fs-filename': 'off', 10 | 'unicorn/import-style': 'off', 11 | 'unicorn/no-null': 'off', 12 | 'unicorn/prevent-abbreviations': 'off', 13 | 'unicorn/prefer-module': 'off', 14 | }, 15 | env: { 16 | node: true, 17 | es6: true, 18 | }, 19 | overrides: [ 20 | { 21 | files: ['*.test.ts', '*.js', '__tests__/**/*.ts'], 22 | rules: { 23 | 'toplevel/no-toplevel-side-effect': 'off', 24 | 'no-magic-numbers': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/ban-ts-comment': 'off', 27 | '@typescript-eslint/no-unsafe-assignment': 'off', 28 | '@typescript-eslint/no-unsafe-member-access': 'off', 29 | '@typescript-eslint/no-unsafe-return': 'off', 30 | '@typescript-eslint/no-unsafe-return': 'off', 31 | '@typescript-eslint/no-unsafe-call': 'off', 32 | '@typescript-eslint/unbound-method': 'off', 33 | }, 34 | }, 35 | ], 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | env: 5 | CI: true 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14, 15] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Cache .pnpm-store 24 | uses: actions/cache@v2 25 | with: 26 | path: ~/.pnpm-store 27 | key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 28 | 29 | - name: Install pnpm 30 | run: curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm@6 31 | 32 | - name: Run install 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: Run lint 36 | run: pnpm run lint 37 | 38 | - name: Run tests 39 | run: pnpm run ci:test 40 | 41 | - name: Coveralls 42 | uses: coverallsapp/github-action@master 43 | with: 44 | github-token: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_run: 8 | workflows: ["Tests"] 9 | types: [completed] 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-18.04 15 | strategy: 16 | matrix: 17 | node-version: [14] 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Cache .pnpm-store 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/.pnpm-store 30 | key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 31 | 32 | - name: Install pnpm 33 | run: curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm@6 34 | 35 | - name: Run install 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Build 39 | run: pnpm run build 40 | 41 | - name: Release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 45 | run: pnpm semantic-release 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tsconfig.build.tsbuildinfo 2 | .eslintcache 3 | dist 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | .idea 32 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname $0)/_/husky.sh" 3 | 4 | pnpm test 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts}": [ 3 | "eslint --fix" 4 | ], 5 | "*.{html,json}": [ 6 | "prettier --write" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !*.md 3 | !dist/**/* 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | strict-peer-dependencies=true 3 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["master"], 3 | "repositoryUrl": "git@github.com:pmb0/express-sharp.git", 4 | "plugins": [ 5 | "@semantic-release/commit-analyzer", 6 | "@semantic-release/release-notes-generator", 7 | ["@semantic-release/changelog", { 8 | "changelogFile": "CHANGELOG.md" 9 | }], 10 | "@semantic-release/npm", 11 | ["@semantic-release/git", { 12 | "assets": ["package.json", "CHANGELOG.md"], 13 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" 14 | }] 15 | ], 16 | "fail": false, 17 | "success": false 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "restart": true, 15 | "outFiles": [ 16 | "${workspaceFolder}/**/*.js" 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": true, 4 | "source.fixAll": true 5 | }, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true, 8 | "editor.formatOnSaveMode": "file", 9 | "editor.semanticHighlighting.enabled": true, 10 | "npm.packageManager": "yarn", 11 | "typescript.tsdk": "node_modules/typescript/lib", 12 | "typescript.referencesCodeLens.enabled": true, 13 | "typescript.implementationsCodeLens.enabled": true, 14 | "typescript.updateImportsOnFileMove.enabled": "always", 15 | "eslint.nodePath": "node_modules/eslint", 16 | "eslint.format.enable": true, 17 | "eslint.packageManager": "yarn", 18 | "eslint.lintTask.options": "--cache .", 19 | "[javascript]": { 20 | "editor.rulers": [80], 21 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 22 | }, 23 | "[javascriptreact]": { 24 | "editor.rulers": [80], 25 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 26 | }, 27 | "[typescript]": { 28 | "editor.rulers": [80], 29 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 30 | }, 31 | "[typescriptreact]": { 32 | "editor.rulers": [80], 33 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [4.2.41](https://github.com/pmb0/express-sharp/compare/v4.2.40...v4.2.41) (2022-04-25) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **deps:** update dependency sharp to ^0.30.0 ([9832bbd](https://github.com/pmb0/express-sharp/commit/9832bbd0ac7c8d3cd9d2ea65129070db8ceaf822)) 7 | 8 | ## [4.2.40](https://github.com/pmb0/express-sharp/compare/v4.2.39...v4.2.40) (2021-10-18) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * import optional deps gracefully ([#554](https://github.com/pmb0/express-sharp/issues/554)) ([e2254dc](https://github.com/pmb0/express-sharp/commit/e2254dc0c1528f709f61ca50a61691218603aed7)) 14 | * keyv is a prod dependency ([4998f56](https://github.com/pmb0/express-sharp/commit/4998f566399b44893df889f68d0d78845d1ecc5a)) 15 | * upgrade deps ([972babb](https://github.com/pmb0/express-sharp/commit/972babbf2a701bd0b78feffbaa07883735a5c9e1)) 16 | 17 | ## [4.2.39](https://github.com/pmb0/express-sharp/compare/v4.2.38...v4.2.39) (2021-09-05) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * release process ([41afb20](https://github.com/pmb0/express-sharp/commit/41afb2006549343746ac1bb081089b1695522e5c)) 23 | 24 | ## [4.2.38](https://github.com/pmb0/express-sharp/compare/v4.2.37...v4.2.38) (2021-09-05) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * upgrade deps ([80c4ae8](https://github.com/pmb0/express-sharp/commit/80c4ae8934d7fefc687620cb20d2685dcb3e4827)) 30 | 31 | ## [4.2.37](https://github.com/pmb0/express-sharp/compare/v4.2.36...v4.2.37) (2021-09-05) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * only require `pnpm` for contributors ([a20813f](https://github.com/pmb0/express-sharp/commit/a20813f4f15cd3576663b2c8496f6c13b7e8c776)), closes [#560](https://github.com/pmb0/express-sharp/issues/560) 37 | 38 | ## [4.2.37](https://github.com/pmb0/express-sharp/compare/v4.2.36...v4.2.37) (2021-09-05) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * only require `pnpm` for contributors ([a20813f](https://github.com/pmb0/express-sharp/commit/a20813f4f15cd3576663b2c8496f6c13b7e8c776)), closes [#560](https://github.com/pmb0/express-sharp/issues/560) 44 | 45 | ## [4.2.36](https://github.com/pmb0/express-sharp/compare/v4.2.35...v4.2.36) (2021-09-04) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * publish only necessary files to npm ([26a9191](https://github.com/pmb0/express-sharp/commit/26a91913b1a771e60fe0131523bd5b9d81af93af)) 51 | 52 | ## [4.2.35](https://github.com/pmb0/express-sharp/compare/v4.2.34...v4.2.35) (2021-09-04) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * aws-sdk and got are optional dependencies ([#554](https://github.com/pmb0/express-sharp/issues/554)) ([5d7f6b3](https://github.com/pmb0/express-sharp/commit/5d7f6b39980156574c08127d366b34dbf087d225)) 58 | 59 | ## [4.2.34](https://github.com/pmb0/express-sharp/compare/v4.2.33...v4.2.34) (2021-08-17) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **deps:** update dependency sharp to ^0.29.0 ([6a82c91](https://github.com/pmb0/express-sharp/commit/6a82c91a9ae30fa04969b365accb7e52aab3f42e)) 65 | 66 | ## [4.2.33](https://github.com/pmb0/express-sharp/compare/v4.2.32...v4.2.33) (2021-07-07) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * **deps:** update dependency tsyringe to v4.6.0 ([4760b4e](https://github.com/pmb0/express-sharp/commit/4760b4e57658ac0250049dd625f3fcb386163463)) 72 | 73 | ## [4.2.32](https://github.com/pmb0/express-sharp/compare/v4.2.31...v4.2.32) (2021-07-04) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * **deps:** update dependency debug to v4.3.2 ([8314be7](https://github.com/pmb0/express-sharp/commit/8314be771b0faf565d12be59c2fe69ec381c87d3)) 79 | 80 | ## [4.2.31](https://github.com/pmb0/express-sharp/compare/v4.2.30...v4.2.31) (2021-06-21) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * upgrade deps ([312298f](https://github.com/pmb0/express-sharp/commit/312298f72c5dd45343290f8884497df4d59ce472)) 86 | 87 | ## [4.2.30](https://github.com/pmb0/express-sharp/compare/v4.2.29...v4.2.30) (2021-06-21) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * resolve memory leak in CachedImage ([f1a8849](https://github.com/pmb0/express-sharp/commit/f1a88496dd2d117dacf79738475d974194cca554)) 93 | 94 | ## [4.2.29](https://github.com/pmb0/express-sharp/compare/v4.2.28...v4.2.29) (2021-05-27) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **deps:** update dependency dotenv to v10 ([7c95fd5](https://github.com/pmb0/express-sharp/commit/7c95fd5fb7fd0298906a9a10f7bbd5b1498147ed)) 100 | 101 | ## [4.2.28](https://github.com/pmb0/express-sharp/compare/v4.2.27...v4.2.28) (2021-05-24) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * **deps:** update dependency sharp to v0.28.3 ([d12c297](https://github.com/pmb0/express-sharp/commit/d12c297894abe3caad252e3243609ae1eed4aab0)) 107 | 108 | ## [4.2.27](https://github.com/pmb0/express-sharp/compare/v4.2.26...v4.2.27) (2021-05-22) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * use EXIF orientation tag ([#343](https://github.com/pmb0/express-sharp/issues/343)) ([f4f9022](https://github.com/pmb0/express-sharp/commit/f4f9022674eab60114f2ea5b75e22441c884fd53)) 114 | 115 | ## [4.2.26](https://github.com/pmb0/express-sharp/compare/v4.2.25...v4.2.26) (2021-05-18) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * upgrade class-validator@^0.13 ([91a55c2](https://github.com/pmb0/express-sharp/commit/91a55c285694c9ba1cce634576d798a71ff3e66d)) 121 | * upgrade dotenv@^9 ([73c6fa2](https://github.com/pmb0/express-sharp/commit/73c6fa2dd74b6f6c532c8d634a8fcb24147983e3)) 122 | * upgrade sharp@^0.28 ([e7c2838](https://github.com/pmb0/express-sharp/commit/e7c28385dd4acf63ad0fbd2d367316e6ecfbfd05)) 123 | 124 | ## [4.2.25](https://github.com/pmb0/express-sharp/compare/v4.2.24...v4.2.25) (2021-05-18) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * `heic` is no longer supported in Sharp ([9da9a73](https://github.com/pmb0/express-sharp/commit/9da9a73d3e5f2a1bcca01fa4c862145368ac5d5e)) 130 | * upgrade deps ([265526a](https://github.com/pmb0/express-sharp/commit/265526af388d281cb3d357c870e1c168e1045a77)) 131 | 132 | ## [4.2.24](https://github.com/pmb0/express-sharp/compare/v4.2.23...v4.2.24) (2021-02-23) 133 | 134 | 135 | ### Bug Fixes 136 | 137 | * **deps:** update dependency sharp to v0.27.2 ([754803d](https://github.com/pmb0/express-sharp/commit/754803d6b90191f997313a105f74ae9031a04fb4)) 138 | 139 | ## [4.2.23](https://github.com/pmb0/express-sharp/compare/v4.2.22...v4.2.23) (2021-01-27) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * **deps:** update dependency sharp to v0.27.1 ([265cec9](https://github.com/pmb0/express-sharp/commit/265cec906cf45bb046736a8d5f4de370db1ebde9)) 145 | 146 | ## [4.2.22](https://github.com/pmb0/express-sharp/compare/v4.2.21...v4.2.22) (2020-12-28) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * **s3-adapter:** catch S3 errors ([9ce4efc](https://github.com/pmb0/express-sharp/commit/9ce4efc6cea2e9f4de5812b03278c223943c2ef7)) 152 | 153 | ## [4.2.21](https://github.com/pmb0/express-sharp/compare/v4.2.20...v4.2.21) (2020-12-28) 154 | 155 | 156 | ### Bug Fixes 157 | 158 | * upgrade deps ([2c9ec46](https://github.com/pmb0/express-sharp/commit/2c9ec4635e069a6be049565739660b3a56bd7986)) 159 | * **s3-adapter:** add missing runtime check ([5140051](https://github.com/pmb0/express-sharp/commit/5140051c54a7ad6a3b8ff59765b0768811c9f731)) 160 | 161 | ## [4.2.20](https://github.com/pmb0/express-sharp/compare/v4.2.19...v4.2.20) (2020-12-26) 162 | 163 | 164 | ### Bug Fixes 165 | 166 | * **deps:** update dependency sharp to ^0.27.0 ([d2d5a10](https://github.com/pmb0/express-sharp/commit/d2d5a10310ea33f5aa18138dbf8b598e4267d58f)) 167 | 168 | ## [4.2.19](https://github.com/pmb0/express-sharp/compare/v4.2.18...v4.2.19) (2020-11-19) 169 | 170 | 171 | ### Bug Fixes 172 | 173 | * **deps:** update dependency debug to v4.3.1 ([aa7feeb](https://github.com/pmb0/express-sharp/commit/aa7feeb73c0d4adbabac13f41302c08a54b16448)) 174 | 175 | ## [4.2.18](https://github.com/pmb0/express-sharp/compare/v4.2.17...v4.2.18) (2020-11-19) 176 | 177 | 178 | ### Bug Fixes 179 | 180 | * **deps:** update dependency debug to v4.3.0 ([57e0948](https://github.com/pmb0/express-sharp/commit/57e094842b1cb8a4e838e75a5ad47acd0d607265)) 181 | 182 | ## [4.2.17](https://github.com/pmb0/express-sharp/compare/v4.2.16...v4.2.17) (2020-11-16) 183 | 184 | 185 | ### Bug Fixes 186 | 187 | * **deps:** update dependency sharp to v0.26.3 ([602a91b](https://github.com/pmb0/express-sharp/commit/602a91b08ae00c2e5932d93ffc33fbe30f42710e)) 188 | 189 | ## [4.2.16](https://github.com/pmb0/express-sharp/compare/v4.2.15...v4.2.16) (2020-11-09) 190 | 191 | 192 | ### Bug Fixes 193 | 194 | * **deps:** update dependency tsyringe to v4.4.0 ([ec4e454](https://github.com/pmb0/express-sharp/commit/ec4e454875f3ddedc7f938a40aaa6342cf68343e)) 195 | 196 | ## [4.2.15](https://github.com/pmb0/express-sharp/compare/v4.2.14...v4.2.15) (2020-10-14) 197 | 198 | 199 | ### Bug Fixes 200 | 201 | * **deps:** update dependency sharp to v0.26.2 ([e3a7203](https://github.com/pmb0/express-sharp/commit/e3a7203cb2a99599db259cd722d39f84e58aa087)) 202 | 203 | ## [4.2.14](https://github.com/pmb0/express-sharp/compare/v4.2.13...v4.2.14) (2020-10-13) 204 | 205 | 206 | ### Bug Fixes 207 | 208 | * improve URL validating ([899baf2](https://github.com/pmb0/express-sharp/commit/899baf2ae7be46d3f841679f27faf183c85c4764)) 209 | 210 | ## [4.2.13](https://github.com/pmb0/express-sharp/compare/v4.2.12...v4.2.13) (2020-09-20) 211 | 212 | 213 | ### Bug Fixes 214 | 215 | * **deps:** update dependency sharp to v0.26.1 ([3ee3294](https://github.com/pmb0/express-sharp/commit/3ee3294a2c31c9b032008be109a11fe40ef42150)) 216 | 217 | ## [4.2.12](https://github.com/pmb0/express-sharp/compare/v4.2.11...v4.2.12) (2020-09-20) 218 | 219 | 220 | ### Bug Fixes 221 | 222 | * validate image URLs more effectively ([8e4ff79](https://github.com/pmb0/express-sharp/commit/8e4ff7939a26469f2db7b13881a8aaf2f0e76289)) 223 | 224 | ## [4.2.11](https://github.com/pmb0/express-sharp/compare/v4.2.10...v4.2.11) (2020-09-20) 225 | 226 | 227 | ### Bug Fixes 228 | 229 | * allow underscores in image file names ([be9fbeb](https://github.com/pmb0/express-sharp/commit/be9fbeb52b745a11572472b30518c7ac522e7515)) 230 | 231 | ## [4.2.10](https://github.com/pmb0/express-sharp/compare/v4.2.9...v4.2.10) (2020-09-19) 232 | 233 | 234 | ### Bug Fixes 235 | 236 | * **deps:** update dependency debug to v4.2.0 ([163558d](https://github.com/pmb0/express-sharp/commit/163558dbbdae3266b31f8468e63ade92c08cb543)) 237 | 238 | ## [4.2.9](https://github.com/pmb0/express-sharp/compare/v4.2.8...v4.2.9) (2020-08-25) 239 | 240 | 241 | ### Bug Fixes 242 | 243 | * **deps:** update dependency sharp to ^0.26.0 ([131bb07](https://github.com/pmb0/express-sharp/commit/131bb07581515ebb1affec8abc3e85080e42cf6f)) 244 | 245 | ## [4.2.8](https://github.com/pmb0/express-sharp/compare/v4.2.7...v4.2.8) (2020-07-11) 246 | 247 | 248 | ### Bug Fixes 249 | 250 | * **exception:** set the `stack` property on exception objects ([5a15e14](https://github.com/pmb0/express-sharp/commit/5a15e14b1be2a8fe2825ef9213b6123ad08964ba)) 251 | 252 | ## [4.2.7](https://github.com/pmb0/express-sharp/compare/v4.2.6...v4.2.7) (2020-07-11) 253 | 254 | 255 | ### Bug Fixes 256 | 257 | * **validation:** handle missing error constraints ([df84f76](https://github.com/pmb0/express-sharp/commit/df84f76186191de587b72fb4d4e7364088cbcbb6)) 258 | 259 | ## [4.2.6](https://github.com/pmb0/express-sharp/compare/v4.2.5...v4.2.6) (2020-07-11) 260 | 261 | 262 | ### Bug Fixes 263 | 264 | * **image-url:** filter query params without values ([563fb07](https://github.com/pmb0/express-sharp/commit/563fb07f24626c33e1de315098bc1baccd7666ff)) 265 | 266 | ## [4.2.5](https://github.com/pmb0/express-sharp/compare/v4.2.4...v4.2.5) (2020-07-11) 267 | 268 | 269 | ### Bug Fixes 270 | 271 | * **deps:** set aws-sdk as peer dependency ([e2be316](https://github.com/pmb0/express-sharp/commit/e2be3160e65671301c2e8f6357edc71955db1ad1)) 272 | * **deps:** set got as peer dependency ([21517e7](https://github.com/pmb0/express-sharp/commit/21517e7f7ad887f8b65b98c4e466458022103a07)) 273 | 274 | ## [4.2.4](https://github.com/pmb0/express-sharp/compare/v4.2.3...v4.2.4) (2020-07-08) 275 | 276 | 277 | ### Bug Fixes 278 | 279 | * **deps:** update dependency got to v11.5.0 ([0d92aaa](https://github.com/pmb0/express-sharp/commit/0d92aaaed50f14190e1039a8ebe597618c6ffa37)) 280 | 281 | ## [4.2.3](https://github.com/pmb0/express-sharp/compare/v4.2.2...v4.2.3) (2020-07-04) 282 | 283 | 284 | ### Bug Fixes 285 | 286 | * **deps:** update dependency got to v11.4.0 ([3f12e2c](https://github.com/pmb0/express-sharp/commit/3f12e2cd0fb7108a30a409d31bf8b218486c90fa)) 287 | 288 | ## [4.2.2](https://github.com/pmb0/express-sharp/compare/v4.2.1...v4.2.2) (2020-06-20) 289 | 290 | 291 | ### Bug Fixes 292 | 293 | * provide missing interface exports ([3cf55e8](https://github.com/pmb0/express-sharp/commit/3cf55e86228edf4478871325037ff6a3899826d9)) 294 | 295 | ## [4.2.1](https://github.com/pmb0/express-sharp/compare/v4.2.0...v4.2.1) (2020-06-20) 296 | 297 | 298 | ### Bug Fixes 299 | 300 | * **crop:** round crop dimensions ([8891d81](https://github.com/pmb0/express-sharp/commit/8891d815ded98b42f4fd61a6d18b437779c1008b)) 301 | 302 | # [4.2.0](https://github.com/pmb0/express-sharp/compare/v4.1.3...v4.2.0) (2020-06-20) 303 | 304 | 305 | ### Bug Fixes 306 | 307 | * do not set image adapter globally ([dbf7911](https://github.com/pmb0/express-sharp/commit/dbf79115ca7c7d7fc8b3effb9fb3e9f7d5ea02ab)) 308 | * image IDs may contain slashes again ([34811d0](https://github.com/pmb0/express-sharp/commit/34811d042e75c5fb78c9ca84161304801a1663a1)) 309 | * slicing the image url seems no longer needed ([35107dc](https://github.com/pmb0/express-sharp/commit/35107dc63bd9f23abd41c27e7ebe53fab8f8d4b4)) 310 | 311 | 312 | ### Features 313 | 314 | * **image-adapters:** add Amazon S3 adapter ([edc628d](https://github.com/pmb0/express-sharp/commit/edc628ded14133c26d784dc00894130c86095427)) 315 | 316 | ## [4.1.3](https://github.com/pmb0/express-sharp/compare/v4.1.2...v4.1.3) (2020-06-18) 317 | 318 | 319 | ### Bug Fixes 320 | 321 | * **deps:** express should be a peer dependency ([d3ada7e](https://github.com/pmb0/express-sharp/commit/d3ada7e983a122aa3808f916ac6b5a8f7db35eab)) 322 | * **npm:** do not bundle config files ([648bc92](https://github.com/pmb0/express-sharp/commit/648bc92dbc6b9cd6b424e3ae60a3adb54913c49c)) 323 | 324 | ## [4.1.2](https://github.com/pmb0/express-sharp/compare/v4.1.1...v4.1.2) (2020-06-18) 325 | 326 | 327 | ### Bug Fixes 328 | 329 | * **npm:** add missing build artifacts ([63012f9](https://github.com/pmb0/express-sharp/commit/63012f9ef76442bba347d294fd1d771bc8dc7476)) 330 | 331 | ## [4.1.1](https://github.com/pmb0/express-sharp/compare/v4.1.0...v4.1.1) (2020-06-18) 332 | 333 | 334 | ### Bug Fixes 335 | 336 | * **deps:** @types/cache-manager is a dev dependency ([0c55b3e](https://github.com/pmb0/express-sharp/commit/0c55b3e0eabad76958a4f8ab0bd639080195b109)) 337 | * **deps:** do not use unmaintained class-transformer ([600c95b](https://github.com/pmb0/express-sharp/commit/600c95b498f703f79a38280458c926105a5e2167)) 338 | * **deps:** update dependency sharp to v0.25.4 ([1bd52bf](https://github.com/pmb0/express-sharp/commit/1bd52bf1e413bf0cf3b5969e81cd9cbf8f3fb691)) 339 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Philipp Busse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Real-time image processing for your express application.

4 |
5 | 6 | [![npm version](https://badge.fury.io/js/express-sharp.svg)](https://www.npmjs.com/package/express-sharp) 7 | [![Test Coverage][coveralls-image]][coveralls-url] 8 | [![Build Status][build-image]][build-url] 9 | 10 | # Description 11 | 12 | express-sharp adds real-time image processing routes to your express application. Images are processed with [sharp](https://github.com/lovell/sharp), a fast Node.js module for resizing images. 13 | 14 | ``` 15 | express-sharp 16 | Express app endpoint image path transformation 17 | ┌─────────────────┐┌────────────────┐┌──────────────────┐ ┌────────┐ 18 | https://example.com/path/to/my-scaler/images/my-image.jpg?w=100&h=50 19 | ``` 20 | 21 | Original images are loaded via an image adapter. Currently this includes HTTP and file system adapters. 22 | 23 | # Highlights 24 | 25 | - Fast resizing of images (see [sharp Performance](https://sharp.pixelplumbing.com/performance)) 26 | - [Supports multiple backends, from which the original images are downloaded](#image-adapters) 27 | - [Supports multiple caching backends](#caching) 28 | - [Image URLs can be signed to prevent attacks](#url-signing) 29 | 30 | 31 | # Table of contents 32 | 33 | - [Install](#install) 34 | - [Express server integration](#express-server-integration) 35 | - [Server configuration](#server-configuration) 36 | - [Image Adapters](#image-adapters) 37 | - [File System](#file-system) 38 | - [HTTP](#http) 39 | - [Amazon S3](#amazon-s3) 40 | - [Custom](#custom) 41 | - [Caching](#caching) 42 | - [URL signing](#url-signing) 43 | - [Debug logging](#debug-logging) 44 | - [Client integration](#client-integration) 45 | - [License](#license) 46 | 47 | # Install 48 | 49 | ```sh 50 | $ yarn add express-sharp 51 | ``` 52 | 53 | See [sharp installation](https://sharp.pixelplumbing.com/install) for additional installation instructions. 54 | 55 | # Express server integration 56 | 57 | Example *app.js* (See also `example/app.ts` in this project): 58 | 59 | ```js 60 | import express from 'express' 61 | import { expressSharp, FsAdapter, HttpAdapter } from 'express-sharp' 62 | 63 | const app = express() 64 | 65 | // Fetch original images via HTTP 66 | app.use( 67 | '/some-http-endpoint', 68 | expressSharp({ 69 | imageAdapter: new HttpAdapter({ 70 | prefixUrl: 'http://example.com/images', 71 | }), 72 | }) 73 | ) 74 | 75 | // Alternative: Load original images from disk 76 | app.use( 77 | '/fs-endpoint', 78 | expressSharp({ 79 | imageAdapter: new FsAdapter(path.join(__dirname, 'images')), 80 | }) 81 | ) 82 | 83 | app.listen(3000) 84 | ``` 85 | 86 | Render `/images/image.jpg` with 400x400 pixels: 87 | 88 | ```sh 89 | curl http://my-server/express-sharp-endpoint/images/image.jpg?w=400&h=400 90 | ``` 91 | 92 | Same as above, but with 80% quality, `webp` image type and with progressive enabled: 93 | 94 | ```sh 95 | curl http://my-server/express-sharp-endpoint/images/image.jpg?w=400&h=400&f=webp&q=80&p 96 | ``` 97 | 98 | ## Server configuration 99 | 100 | ```js 101 | import { expressSharp } from 'express-sharp' 102 | 103 | app.use('/some-http-endpoint', expressSharp(options)) 104 | ``` 105 | 106 | Supported `options`: 107 | 108 | | Name | Description | Default | 109 | |------|-------------|---------| 110 | | `autoUseWebp` | Specifies whether images should automatically be rendered in webp format when supported by the browser. | `true` | 111 | | `cache` | If specified, the [keyv cache]((https://github.com/lukechilds/keyv)) configured here is used to cache the retrieval of the original images and the transformations. | - | 112 | | `cors` | Any valid [CORS configuration option](https://expressjs.com/en/resources/middleware/cors.html) | - | 113 | | `imageAdapter` | Configures the image adapter to be used (see below). Must be specified. | - | 114 | | `secret` | If specified, express-sharp will validate the incoming request to verify that a valid signature has been provided. The secret is used to compute this signature. | - | 115 | 116 | ## Image Adapters 117 | 118 | express-sharp contains the following standard image adapters. 119 | 120 | ### File System 121 | 122 | With this adapter original images are loaded from the hard disk. 123 | 124 | ```js 125 | import { FsAdapter } from 'express-sharp' 126 | 127 | const adapter = new FsAdapter('/path/to/images') 128 | ``` 129 | 130 | ### HTTP 131 | 132 | Loads original images via HTTP. To use this adapter, the peer dependency `got` must be installed: 133 | 134 | ```sh 135 | $ yarn add got 136 | ``` 137 | 138 | ```js 139 | import { HttpAdapter } from 'express-sharp' 140 | 141 | const adapter = new HttpAdapter({ 142 | prefixUrl: 'http://localhost:3000/images', 143 | }) 144 | ``` 145 | 146 | The constructor can be passed any [got options](https://github.com/sindresorhus/got#options). 147 | 148 | ### Amazon S3 149 | 150 | Loads images from Amazon S3. To use this adapter, the peer dependency `aws-sdk` must be installed: 151 | 152 | ```sh 153 | $ yarn add aws-sdk 154 | ``` 155 | 156 | ```js 157 | import { S3Adapter } from 'express-sharp' 158 | 159 | const bucketName = 'my-bucketname' 160 | const adapter = new S3Adapter(bucketname) 161 | ``` 162 | 163 | The AWS SDK expects the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to be set. 164 | 165 | ### Custom 166 | 167 | If you needed your own adapters can be used. An "image adapter" is a class that implements the `ImageAdapter` interface: 168 | 169 | ```ts 170 | import { ImageAdapter } from 'express-sharp' 171 | 172 | class MyAdapter implements ImageAdapter { 173 | async fetch(id: string): Promise { 174 | if (imageDoesNotExist(id)) { 175 | return undefined 176 | } 177 | 178 | return Buffer.from('my image blob') 179 | } 180 | } 181 | ``` 182 | 183 | ## Caching 184 | 185 | The fetching of the original images and the transformations can be cached. To enable this feature, the `cache` option must be passed to the `expressSharp` middleware. Any [keyv cache stores](https://github.com/lukechilds/keyv) can be passed. 186 | 187 | 188 | In-memory cache example: 189 | 190 | ```js 191 | const cache = new Keyv({ namespace: 'express-sharp' }) 192 | 193 | app.use( 194 | '/my-endpoint', 195 | expressSharp({ 196 | cache, 197 | imageAdapter: ... 198 | }) 199 | ) 200 | ``` 201 | 202 | Redis example: 203 | 204 | ```js 205 | const cache = new Keyv('redis://', { namespace: 'express-sharp' } 206 | 207 | app.use( 208 | '/my-endpoint', 209 | expressSharp({ 210 | cache, 211 | imageAdapter: ... 212 | }) 213 | ) 214 | ``` 215 | 216 | ## URL signing 217 | 218 | By setting the environment variable `EXPRESS_SHARP_SIGNED_URL_SECRET` or by specifying the `secret` option when calling the `express-sharp` middleware, signed URLs are activated. This reduces the attack surface on the server, since the caller cannot produce an unlimited number of URLs that cause load on the server. 219 | 220 | In order to compute the signature, the supplied client should be used: 221 | 222 | ```js 223 | import { createClient } from 'express-sharp' 224 | 225 | const endpoint = 'https://example.com/my-express-sharp-endpoint' 226 | const secret = 'test' 227 | const client = createClient(endpoint, secret) 228 | 229 | const imageUrl = client.url('/foo.png', { width: 500 }) 230 | 231 | // https://example.com/my-express-sharp-endpoint/foo.png?w=500&s=Of3ty8QY-NDhCsIrgIHvPvbokkDcxV8KtaYUB4NFRd8 232 | ``` 233 | 234 | ## Debug logging 235 | 236 | This project uses [debug](https://www.npmjs.com/package/debug). To display debug messages from `express-sharp`, the `DEBUG` environment variable must be exported so that it contains the value `express-sharp*`. Example: 237 | 238 | ```sh 239 | $ export DEBUG='my-app:*,express-sharp*' 240 | ``` 241 | 242 | 243 | # Client integration 244 | 245 | express-sharp comes with a client that can be used to generate URLs for images. 246 | 247 | ```js 248 | import { createClient } from 'express-sharp' 249 | 250 | const client = createClient('http://my-base-host', 'optional secret') 251 | 252 | const originalImageUrl = '/foo.png' 253 | const options = { width: 500 } 254 | const fooUrl = client.url(originalImageUrl, options) 255 | ``` 256 | 257 | Currently the following transformations can be applied to images: 258 | 259 | | Client option name | Query param name | Description | 260 | |--------------------|------------------|-------------| 261 | | quality | `q` | Quality is a number between 1 and 100 (see [sharp docs](https://sharp.pixelplumbing.com/en/stable/api-output/)). | 262 | | width | `w` | 263 | | height | `h` | 264 | | format | `f` | Output image format. Valid values: every valid [sharp output format string](https://sharp.pixelplumbing.com/api-output#toformat), i.e. `jpeg`, `gif`, `webp` or `raw`. | 265 | | progressive | `p` | Only available for jpeg and png formats. Enable progressive scan by passing `true`. | 266 | | crop | `c` | Setting crop to `true` enables the [sharp cropping feature](https://sharp.pixelplumbing.com/api-resize#crop). Note: Both `width` and `height` params are neccessary for crop to work. Default is `false`. | 267 | | trim | `t` | Setting trim to `true` enables the [sharp trim feature](https://sharp.pixelplumbing.com/api-resize#trim). | 268 | | gravity | `g` | When the crop option is activated you can specify the gravity of the cropping. Possible attributes of the optional `gravity` are `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `center` and `centre`. Default is `center`. | 269 | 270 | # License 271 | 272 | express-sharp is distributed under the MIT license. [See LICENSE](./LICENSE) for details. 273 | 274 | [coveralls-image]: https://img.shields.io/coveralls/pmb0/express-sharp/master.svg 275 | [coveralls-url]: https://coveralls.io/r/pmb0/express-sharp?branch=master 276 | [build-image]: https://github.com/pmb0/express-sharp/workflows/Tests/badge.svg 277 | [build-url]: https://github.com/pmb0/express-sharp/actions?query=workflow%3ATests 278 | -------------------------------------------------------------------------------- /__mocks__/fs.ts: -------------------------------------------------------------------------------- 1 | export const promises = { 2 | readFile: jest.fn(), 3 | } 4 | -------------------------------------------------------------------------------- /__mocks__/got.ts: -------------------------------------------------------------------------------- 1 | export default class GotMock { 2 | static extend = jest.fn().mockReturnValue(new GotMock()) 3 | 4 | defaults = { 5 | options: {}, 6 | } 7 | 8 | get = jest.fn().mockReturnValue({ body: Buffer.from('test') }) 9 | } 10 | -------------------------------------------------------------------------------- /__tests__/cors.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { AddressInfo } from 'net' 3 | import { join } from 'path' 4 | import request from 'supertest' 5 | import { createClient, expressSharp, FsAdapter } from '../src' 6 | 7 | const app = express() 8 | const server = app.listen() 9 | const { address, port } = server.address() as AddressInfo 10 | 11 | const scale1Url = createClient(`http://[${address}]:${port}/scale1`) 12 | const scale2Url = createClient(`http://[${address}]:${port}/scale2`) 13 | 14 | const imageAdapter = new FsAdapter(join(__dirname, 'images')) 15 | 16 | app.use('/scale1', expressSharp({ imageAdapter })) 17 | app.use( 18 | '/scale2', 19 | expressSharp({ cors: { origin: 'http://example.com' }, imageAdapter }), 20 | ) 21 | 22 | afterAll(() => server.close()) 23 | describe('Test CORS', () => { 24 | it('should send Access-Control-Allow-Origin:* header', async () => { 25 | await request(app) 26 | .get(scale1Url.pathQuery('/a.jpg', { width: 110 })) 27 | .expect('Access-Control-Allow-Origin', '*') 28 | }) 29 | 30 | it('should send a custom Access-Control-Allow-Origin header', async () => { 31 | await request(app) 32 | .get(scale2Url.pathQuery('/a.jpg', { width: 110 })) 33 | .expect('Access-Control-Allow-Origin', 'http://example.com') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /__tests__/express-sharp.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { AddressInfo } from 'net' 3 | import { join } from 'path' 4 | import 'reflect-metadata' 5 | import sharp from 'sharp' 6 | import request from 'supertest' 7 | import { URL } from 'url' 8 | import { createClient, expressSharp, FsAdapter } from '../src' 9 | 10 | const imageAdapter = new FsAdapter(join(__dirname, 'images')) 11 | const app = express() 12 | const server = app.listen() 13 | 14 | app.use('/my-scale', expressSharp({ imageAdapter })) 15 | 16 | const { address, port } = server.address() as AddressInfo 17 | 18 | const client = createClient(`http://[${address}]:${port}/my-scale`) 19 | 20 | afterAll(() => server.close()) 21 | 22 | function url(...args: Parameters): string { 23 | const url = new URL(client.url(...args)) 24 | return url.pathname + url.search 25 | } 26 | 27 | describe('GET /my-scale/resize', () => { 28 | it('should respond with 404', async () => { 29 | await request(app).get('/my-scale/resize').expect(404) 30 | }) 31 | 32 | it('should respond with 400 (invalid width)', async () => { 33 | await request(app).get('/my-scale/whatever?w=100a').expect(400) 34 | }) 35 | 36 | it('should respond with 400 if quality is invalid', async () => { 37 | await request(app) 38 | .get(url('/a.jpg', { quality: -1, width: 100 })) 39 | .expect(400) 40 | }) 41 | 42 | it('should respond with 200 (quality=1)', async () => { 43 | await request(app) 44 | .get(url('/a.jpg', { quality: 1, width: 100 })) 45 | .expect(200) 46 | }) 47 | 48 | it('should respond with 400 (invalid quality)', async () => { 49 | await request(app) 50 | .get(url('/a.jpg', { quality: 101, width: 100 })) 51 | .expect(400) 52 | }) 53 | 54 | it('should respond with 200 (quality=100)', async () => { 55 | await request(app) 56 | .get(url('/a.jpg', { quality: 100, width: 100 })) 57 | .expect(200) 58 | }) 59 | 60 | it('should respond with 404 (image id does not exist)', async () => { 61 | await request(app) 62 | .get(url('/does-not-exist.jpg', { width: 100 })) 63 | .expect(404) 64 | }) 65 | 66 | it('should resize /images/a.jpg to 100px', async () => { 67 | const res = await request(app) 68 | .get(url('/a.jpg', { width: 100 })) 69 | .expect(200) 70 | 71 | expect(res.body.byteLength).toBeLessThan(5000) 72 | 73 | const { width } = await sharp(res.body).metadata() 74 | expect(width).toBe(100) 75 | }) 76 | 77 | it('should resize /images/a.jpg to 110px, 5% quality', async () => { 78 | const res = await request(app) 79 | .get(url('/a.jpg', { quality: 5, width: 110 })) 80 | .expect(200) 81 | expect(res.body.byteLength).toBeLessThan(5000) 82 | const { width } = await sharp(res.body).metadata() 83 | expect(width).toBe(110) 84 | }) 85 | 86 | it('should change content type to image/png', async () => { 87 | await request(app) 88 | .get(url('/a.jpg', { format: 'png', width: 110 })) 89 | .expect('Content-Type', 'image/png') 90 | .expect(200) 91 | }) 92 | 93 | it('should auto detect content type png', async () => { 94 | await request(app) 95 | .get(url('/b.png', { width: 110 })) 96 | .expect('Content-Type', 'image/png') 97 | .expect(200) 98 | }) 99 | 100 | it('should auto detect content type jpeg', () => { 101 | return request(app) 102 | .get(url('/a.jpg', { width: 110 })) 103 | .expect('Content-Type', 'image/jpeg') 104 | .expect(200) 105 | }) 106 | 107 | // (false-positive) 108 | // eslint-disable-next-line jest/expect-expect 109 | it('should use webp if supported', async () => { 110 | await request(app) 111 | .get(url('/a.jpg', { width: 110 })) 112 | .set('Accept', 'image/webp') 113 | .expect('Content-Type', 'image/webp') 114 | .expect(200) 115 | }) 116 | 117 | it('should crop /images/a.jpg to 55px x 42px', async () => { 118 | const response = await request(app) 119 | .get( 120 | url('/a.jpg', { 121 | crop: true, 122 | gravity: 'west', 123 | height: 42, 124 | width: 55, 125 | }), 126 | ) 127 | .expect(200) 128 | 129 | const { width, height } = await sharp(response.body).metadata() 130 | expect(width).toBe(55) 131 | expect(height).toBe(42) 132 | }) 133 | 134 | it('should restrict crop to cropMaxSize (width > height)', async () => { 135 | const res = await request(app) 136 | .get( 137 | url('/a.jpg', { 138 | crop: true, 139 | height: 2000, 140 | width: 4000, 141 | }), 142 | ) 143 | .expect(200) 144 | const { width, height } = await sharp(res.body).metadata() 145 | expect(width).toBe(2000) 146 | expect(height).toBe(1000) 147 | }) 148 | 149 | it('should restrict crop to cropMaxSize (height > width)', async () => { 150 | const res = await request(app) 151 | .get( 152 | url('/a.jpg', { 153 | crop: true, 154 | height: 6000, 155 | width: 3000, 156 | }), 157 | ) 158 | .expect(200) 159 | const { width, height } = await sharp(res.body).metadata() 160 | expect(width).toBe(1000) 161 | expect(height).toBe(2000) 162 | }) 163 | 164 | it('should respond with 400 with wrong gravity', async () => { 165 | await request(app) 166 | .get( 167 | url('/a.jpg', { 168 | crop: true, 169 | // @ts-ignore 170 | gravity: 'does not exist', 171 | 172 | height: 100, 173 | 174 | width: 100, 175 | }), 176 | ) 177 | .expect(400) 178 | }) 179 | 180 | it('should contain ETag header', async () => { 181 | await request(app) 182 | .get(url('/a.jpg', { width: 110 })) 183 | .expect('ETag', /W\/".*"/) 184 | .expect(200) 185 | }) 186 | 187 | it('should use If-None-Match header', async () => { 188 | // If this test fails, the If-None-Match value may need to be updated. 189 | const response = await request(app) 190 | .get(url('/a.jpg', { width: 110 })) 191 | .set('If-None-Match', 'W/"55-N6qlcSh59aTpUfPRkyE1N1BiYmk"') 192 | 193 | expect(response.body).toEqual({}) 194 | }) 195 | 196 | it('allows underscores in file names', async () => { 197 | await request(app).get(url('/foo-_A.jpg', {})).expect(200) 198 | }) 199 | 200 | it('transforms zade4np6qh-bardokat-designs-makramee-schlusselanhanger-noa-hellgrau.jpg', async () => { 201 | await request(app) 202 | .get( 203 | url( 204 | '/zade4np6qh-bardokat-designs-makramee-schlusselanhanger-noa-hellgrau.jpg', 205 | {}, 206 | ), 207 | ) 208 | .expect(200) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /__tests__/images/a.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmb0/express-sharp/c511a19cfe8be697709dd7c44c2b6dd752941f1d/__tests__/images/a.jpg -------------------------------------------------------------------------------- /__tests__/images/b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmb0/express-sharp/c511a19cfe8be697709dd7c44c2b6dd752941f1d/__tests__/images/b.png -------------------------------------------------------------------------------- /__tests__/images/foo-_A.jpg: -------------------------------------------------------------------------------- 1 | b.png -------------------------------------------------------------------------------- /__tests__/images/zade4np6qh-bardokat-designs-makramee-schlusselanhanger-noa-hellgrau.jpg: -------------------------------------------------------------------------------- 1 | b.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /docs/express-sharp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmb0/express-sharp/c511a19cfe8be697709dd7c44c2b6dd752941f1d/docs/express-sharp.gif -------------------------------------------------------------------------------- /example/app.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable toplevel/no-toplevel-side-effect */ 2 | import express from 'express' 3 | import Keyv from 'keyv' 4 | import { AddressInfo } from 'net' 5 | import { join } from 'path' 6 | import { expressSharp, FsAdapter, HttpAdapter, S3Adapter } from '../src' 7 | 8 | // Cache in-memory 9 | const cache = new Keyv({ namespace: 'express-sharp' }) 10 | 11 | // Cache using Redis 12 | // const cache = new Keyv('redis://', { namespace: 'express-sharp' }) 13 | 14 | const app = express() 15 | const PORT = 3000 16 | 17 | app.use(express.static(join(__dirname, 'public'))) 18 | app.use(express.static(join(__dirname, 'images'))) 19 | 20 | app.use( 21 | '/local-http', 22 | expressSharp({ 23 | cache, 24 | imageAdapter: new HttpAdapter({ 25 | prefixUrl: 'http://localhost:3000/', 26 | }), 27 | }), 28 | ) 29 | 30 | const awsBucket = process.env.AWS_BUCKET 31 | if (!awsBucket) { 32 | throw new Error('AWS_BUCKET not set') 33 | } 34 | 35 | app.use( 36 | '/s3', 37 | expressSharp({ 38 | cache: new Keyv(), 39 | imageAdapter: new S3Adapter(awsBucket), 40 | }), 41 | ) 42 | 43 | app.use( 44 | '/lorempixel', 45 | expressSharp({ 46 | cache, 47 | imageAdapter: new HttpAdapter({ prefixUrl: 'http://lorempixel.com' }), 48 | }), 49 | ) 50 | app.use( 51 | '/fs', 52 | expressSharp({ 53 | cache, 54 | imageAdapter: new FsAdapter(join(__dirname, 'images')), 55 | }), 56 | ) 57 | 58 | app.set('views', join(__dirname, 'views')) 59 | app.set('view engine', 'pug') 60 | 61 | app.get('/', (req, res) => { 62 | res.render('index', { title: 'express-sharp example' }) 63 | }) 64 | 65 | const server = app.listen(PORT, function () { 66 | const { address, port } = server.address() as AddressInfo 67 | // eslint-disable-next-line no-console 68 | console.log('✔ Example app listening at http://%s:%s', address, port) 69 | }) 70 | -------------------------------------------------------------------------------- /example/images/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmb0/express-sharp/c511a19cfe8be697709dd7c44c2b6dd752941f1d/example/images/1.jpeg -------------------------------------------------------------------------------- /example/public/example.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable toplevel/no-toplevel-side-effect */ 2 | 3 | import { TestImage } from './image.mjs' 4 | import { setValue } from './utils.mjs' 5 | 6 | const template = document.createElement('template') 7 | 8 | template.innerHTML = ` 9 | 10 | 11 | 26 | 27 |
28 | 29 | 35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 | 52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 | 72 |
73 | 74 |

URL:

75 |
76 | 77 |
78 | ` 79 | 80 | export class TestExample extends HTMLElement { 81 | static observedAttributes = [ 82 | 'base', 83 | 'url', 84 | 'quality', 85 | 'width', 86 | 'height', 87 | 'crop', 88 | ] 89 | 90 | constructor() { 91 | super() 92 | 93 | this.attachShadow({ mode: 'open' }) 94 | this.shadowRoot.append(template.content.cloneNode(true)) 95 | } 96 | 97 | connectedCallback() { 98 | TestExample.observedAttributes.forEach((attribute) => { 99 | this.shadowRoot 100 | .querySelector(`#${attribute}`) 101 | ?.addEventListener('change', (event) => { 102 | console.log('Set:', attribute, event.target.value) 103 | this.setAttribute(attribute, event.target.value) 104 | }) 105 | }) 106 | } 107 | 108 | attributeChangedCallback(name, oldValue, newValue) { 109 | this.testImage.setAttribute(name, newValue) 110 | 111 | this.shadowRoot.querySelector( 112 | 'code' 113 | ).textContent = this.testImage.buildUrl() 114 | 115 | setValue(this.shadowRoot.querySelector(`#${name}`), newValue) 116 | } 117 | 118 | get testImage() { 119 | return this.shadowRoot.querySelector('test-image') 120 | } 121 | 122 | get base() { 123 | return this.getAttribute('base') 124 | } 125 | 126 | get url() { 127 | return this.getAttribute('url') 128 | } 129 | 130 | get quality() { 131 | return this.getAttribute('quality') 132 | } 133 | } 134 | customElements.define('test-example', TestExample) 135 | -------------------------------------------------------------------------------- /example/public/image.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable toplevel/no-toplevel-side-effect */ 2 | 3 | const template = document.createElement('template') 4 | 5 | template.innerHTML = ` 6 | 11 | 12 | 13 | ` 14 | 15 | export class TestImage extends HTMLElement { 16 | static observedAttributes = [ 17 | 'base', 18 | 'crop', 19 | 'height', 20 | 'quality', 21 | 'url', 22 | 'width', 23 | ] 24 | 25 | constructor() { 26 | super() 27 | 28 | this.attachShadow({ mode: 'open' }) 29 | this.shadowRoot.append(template.content.cloneNode(true)) 30 | } 31 | 32 | attributeChangedCallback(name, oldValue, newValue) { 33 | this.updateSrc() 34 | } 35 | 36 | buildUrl() { 37 | if (!this.url) return null 38 | 39 | const url = new URL(`${this.base}${this.url}`, document.location.href) 40 | if (this.width) url.searchParams.set('w', this.width) 41 | if (this.height) url.searchParams.set('h', this.height) 42 | 43 | if (this.quality) url.searchParams.set('quality', this.quality) 44 | if (this.crop) { 45 | url.searchParams.set('crop', 'true') 46 | url.searchParams.set('gravity', this.crop) 47 | } 48 | return url.toString() 49 | } 50 | 51 | updateSrc() { 52 | const src = this.buildUrl() 53 | 54 | if (src) this.img.src = src 55 | } 56 | 57 | get url() { 58 | return this.getAttribute('url') 59 | } 60 | 61 | get base() { 62 | return this.getAttribute('base') 63 | } 64 | 65 | get quality() { 66 | return this.getAttribute('quality') 67 | } 68 | 69 | get width() { 70 | return this.getAttribute('width') 71 | } 72 | 73 | get height() { 74 | return this.getAttribute('height') 75 | } 76 | 77 | get crop() { 78 | return this.getAttribute('crop') 79 | } 80 | 81 | get img() { 82 | return this.shadowRoot.querySelector('img') 83 | } 84 | } 85 | customElements.define('test-image', TestImage) 86 | -------------------------------------------------------------------------------- /example/public/main.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmb0/express-sharp/c511a19cfe8be697709dd7c44c2b6dd752941f1d/example/public/main.css -------------------------------------------------------------------------------- /example/public/utils.mjs: -------------------------------------------------------------------------------- 1 | export function setValue(element, newValue) { 2 | if (!element) return 3 | 4 | switch (element.tagName) { 5 | case 'SELECT': 6 | element.selectedIndex = [...element.querySelectorAll('option')] 7 | .map((element) => element.value) 8 | .indexOf(newValue) 9 | 10 | break 11 | 12 | default: 13 | element.value = newValue 14 | break 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1 #{title} 5 | 6 | test-example( 7 | base="/fs" 8 | url="/1.jpeg" 9 | width="2000" 10 | ) 11 | 12 | test-example( 13 | base="/fs" 14 | url="/1.jpeg" 15 | quality="5" 16 | width="200" 17 | height="300" 18 | crop="east" 19 | ) 20 | 21 | test-example( 22 | base="/fs" 23 | url="/1.jpeg" 24 | quality="90" 25 | width="200" 26 | height="400" 27 | crop="east" 28 | ) 29 | 30 | test-example( 31 | base="/lorempixel" 32 | url="/500/500" 33 | width="500" 34 | ) 35 | -------------------------------------------------------------------------------- /example/views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='') 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge') 6 | meta(name='viewport', content='width=device-width, initial-scale=1.0') 7 | title #{title} 8 | link(rel='stylesheet', href='main.css') 9 | link(href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css", rel="stylesheet", integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh", crossorigin="anonymous") 10 | script(type='module' async src='example.mjs') 11 | 12 | body 13 | .container.pl-sm-0.pr-sm-0.pl-lg-3.pr-lg-3 14 | block content 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable toplevel/no-toplevel-side-effect */ 2 | // For a detailed explanation regarding each configuration property, visit: 3 | // https://jestjs.io/docs/en/configuration.html 4 | 5 | module.exports = { 6 | // All imported modules in your tests should be mocked automatically 7 | // automock: false, 8 | 9 | // Stop running tests after `n` failures 10 | // bail: 0, 11 | 12 | // Respect "browser" field in package.json when resolving modules 13 | // browser: false, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "/private/var/folders/3z/vt4dq0w132b_kg4gm2s7j33c0000gn/T/jest_dx", 17 | 18 | // Automatically clear mock calls and instances between every test 19 | clearMocks: true, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | // collectCoverage: false, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | collectCoverageFrom: ['index.ts', './src/**'], 26 | 27 | // The directory where Jest should output its coverage files 28 | coverageDirectory: 'coverage', 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | coveragePathIgnorePatterns: ['.dto.ts$'], 32 | 33 | // A list of reporter names that Jest uses when writing coverage reports 34 | coverageReporters: ['text', 'lcov'], 35 | 36 | // An object that configures minimum threshold enforcement for coverage results 37 | // coverageThreshold: undefined, 38 | 39 | // A path to a custom dependency extractor 40 | // dependencyExtractor: undefined, 41 | 42 | // Make calling deprecated APIs throw helpful error messages 43 | // errorOnDeprecated: false, 44 | 45 | // Force coverage collection from ignored files using an array of glob patterns 46 | // forceCoverageMatch: [], 47 | 48 | // A path to a module which exports an async function that is triggered once before all test suites 49 | // globalSetup: undefined, 50 | 51 | // A path to a module which exports an async function that is triggered once after all test suites 52 | // globalTeardown: undefined, 53 | 54 | // A set of global variables that need to be available in all test environments 55 | globals: { 56 | 'ts-jest': { 57 | tsconfig: 'tsconfig.json', 58 | }, 59 | }, 60 | 61 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 62 | // maxWorkers: "50%", 63 | 64 | // An array of directory names to be searched recursively up from the requiring module's location 65 | // moduleDirectories: [ 66 | // "node_modules" 67 | // ], 68 | 69 | // An array of file extensions your modules use 70 | moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], 71 | 72 | // A map from regular expressions to module names that allow to stub out resources with a single module 73 | // moduleNameMapper: {}, 74 | 75 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 76 | // modulePathIgnorePatterns: [], 77 | 78 | // Activates notifications for test results 79 | // notify: false, 80 | 81 | // An enum that specifies notification mode. Requires { notify: true } 82 | // notifyMode: "failure-change", 83 | 84 | // A preset that is used as a base for Jest's configuration 85 | // preset: undefined, 86 | 87 | // Run tests from one or more projects 88 | // projects: undefined, 89 | 90 | // Use this configuration option to add custom reporters to Jest 91 | // reporters: undefined, 92 | 93 | // Automatically reset mock state between every test 94 | // resetMocks: false, 95 | 96 | // Reset the module registry before running each individual test 97 | // resetModules: false, 98 | 99 | // A path to a custom resolver 100 | // resolver: undefined, 101 | 102 | // Automatically restore mock state between every test 103 | // restoreMocks: false, 104 | 105 | // The root directory that Jest should scan for tests and modules within 106 | // rootDir: undefined, 107 | 108 | // A list of paths to directories that Jest should use to search for files in 109 | // roots: [ 110 | // "" 111 | // ], 112 | 113 | // Allows you to use a custom runner instead of Jest's default test runner 114 | // runner: "jest-runner", 115 | 116 | // The paths to modules that run some code to configure or set up the testing environment before each test 117 | setupFiles: ['./test/setup.ts'], 118 | 119 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 120 | // setupFilesAfterEnv: [], 121 | 122 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 123 | // snapshotSerializers: [], 124 | 125 | // The test environment that will be used for testing 126 | testEnvironment: 'node', 127 | 128 | // Options that will be passed to the testEnvironment 129 | // testEnvironmentOptions: {}, 130 | 131 | // Adds a location field to test results 132 | // testLocationInResults: false, 133 | 134 | // The glob patterns Jest uses to detect test files 135 | // testMatch: [ 136 | // "**/__tests__/**/*.[jt]s?(x)", 137 | // "**/?(*.)+(spec|test).[tj]s?(x)" 138 | // ], 139 | 140 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 141 | testPathIgnorePatterns: ['/dist/'], 142 | 143 | // The regexp pattern or array of patterns that Jest uses to detect test files 144 | // testRegex: [], 145 | 146 | // This option allows the use of a custom results processor 147 | // testResultsProcessor: undefined, 148 | 149 | // This option allows use of a custom test runner 150 | // testRunner: "jasmine2", 151 | 152 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 153 | // testURL: "http://localhost", 154 | 155 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 156 | // timers: "real", 157 | 158 | // A map from regular expressions to paths to transformers 159 | transform: { 160 | '^.+\\.(ts|tsx)$': 'ts-jest', 161 | }, 162 | 163 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 164 | // transformIgnorePatterns: [ 165 | // "/node_modules/" 166 | // ], 167 | 168 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 169 | modulePathIgnorePatterns: ["/dist/"] 170 | 171 | // Indicates whether each individual test should be reported during the run 172 | // verbose: undefined, 173 | 174 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 175 | // watchPathIgnorePatterns: [], 176 | 177 | // Whether to use watchman for file crawling 178 | // watchman: true, 179 | } 180 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-sharp", 3 | "version": "4.2.41", 4 | "description": "Real-time image processing for your express application", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "dependencies": { 8 | "class-validator": "^0.13.1", 9 | "cors": "^2.8.5", 10 | "debug": "^4.3.2", 11 | "dotenv": "^10.0.0", 12 | "etag": "^1.8.1", 13 | "keyv": "^4.0.3", 14 | "reflect-metadata": "^0.1.13", 15 | "sharp": "^0.30.0", 16 | "tsyringe": "^4.6.0" 17 | }, 18 | "devDependencies": { 19 | "@commitlint/cli": "^17.0.0", 20 | "@commitlint/config-conventional": "^17.0.0", 21 | "@heise/eslint-config": "^19.0.13", 22 | "@keyv/redis": "^2.1.3", 23 | "@semantic-release/changelog": "^6.0.0", 24 | "@semantic-release/commit-analyzer": "^9.0.1", 25 | "@semantic-release/git": "^10.0.0", 26 | "@semantic-release/npm": "^9.0.0", 27 | "@semantic-release/release-notes-generator": "^10.0.2", 28 | "@types/cache-manager": "^4.0.0", 29 | "@types/cors": "^2.8.12", 30 | "@types/debug": "^4.1.7", 31 | "@types/etag": "^1.8.1", 32 | "@types/express": "^4.17.13", 33 | "@types/got": "^9.6.12", 34 | "@types/jest": "^27.0.2", 35 | "@types/keyv": "^3.1.3", 36 | "@types/node": "^14.17.27", 37 | "@types/sharp": "^0.30.0", 38 | "@types/supertest": "^2.0.11", 39 | "aws-sdk": "^2.1009.0", 40 | "eslint-plugin-no-only-tests": "^2.6.0", 41 | "eslint-plugin-sort-keys-fix": "^1.1.2", 42 | "express": "^4.17.1", 43 | "got": "^11.8.2", 44 | "husky": "^8.0.0", 45 | "jest": "^27.3.0", 46 | "lint-staged": "^13.0.0", 47 | "nodemon": "^2.0.13", 48 | "prettier": "^2.4.1", 49 | "pug": "^3.0.2", 50 | "rimraf": "^3.0.2", 51 | "semantic-release": "^19.0.0", 52 | "supertest": "^6.1.6", 53 | "ts-jest": "^27.0.7", 54 | "ts-node": "^10.3.0", 55 | "typescript": "^4.4.4" 56 | }, 57 | "peerDependencies": { 58 | "aws-sdk": "^2.713.0", 59 | "express": "^4.0.0", 60 | "got": "^11.5.0" 61 | }, 62 | "peerDependenciesMeta": { 63 | "aws-sdk": { 64 | "optional": true 65 | }, 66 | "got": { 67 | "optional": true 68 | } 69 | }, 70 | "scripts": { 71 | "build:test": "tsc --noEmit", 72 | "build": "tsc --build tsconfig.build.json", 73 | "prebuild": "yarn clean", 74 | "clean": "rimraf dist", 75 | "start:example": "DEBUG=express-sharp* nodemon --exec 'node -r ts-node/register' -w . --inspect example/app.ts", 76 | "lint": "eslint --cache .", 77 | "test": "jest", 78 | "ci:test": "jest --coverage", 79 | "prepare": "husky install" 80 | }, 81 | "engines": { 82 | "node": ">= 12.0.0" 83 | }, 84 | "repository": { 85 | "type": "git", 86 | "url": "git+https://github.com/pmb0/express-sharp.git" 87 | }, 88 | "keywords": [ 89 | "express", 90 | "sharp", 91 | "image", 92 | "scale" 93 | ], 94 | "author": "Philipp Busse", 95 | "license": "MIT", 96 | "bugs": { 97 | "url": "https://github.com/pmb0/express-sharp/issues" 98 | }, 99 | "homepage": "https://github.com/pmb0/express-sharp#readme" 100 | } 101 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":separateMajorReleases", 5 | ":combinePatchMinorReleases", 6 | ":ignoreUnstable", 7 | ":prImmediately", 8 | ":renovatePrefix", 9 | ":semanticCommits", 10 | ":semanticPrefixFixDepsChoreOthers", 11 | ":updateNotScheduled", 12 | ":ignoreModulesAndTests", 13 | "group:monorepos", 14 | "group:recommended", 15 | "helpers:disableTypesNodeMajor" 16 | ], 17 | "rangeStrategy": "update-lockfile", 18 | "packageRules": [ 19 | { 20 | "depTypeList": ["devDependencies"], 21 | "extends": ["schedule:weekly"], 22 | "automerge": true 23 | } 24 | ], 25 | "automerge": true, 26 | "major": { 27 | "automerge": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/adapter/fs.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import { mocked } from 'ts-jest/utils' 3 | import { FsAdapter } from './fs.adapter' 4 | 5 | jest.mock('fs') 6 | 7 | describe('FsAdapter', () => { 8 | let adapter: FsAdapter 9 | beforeEach(() => { 10 | adapter = new FsAdapter('/tmp') 11 | }) 12 | 13 | describe('fetch()', () => { 14 | it('returns the image', async () => { 15 | // @ts-ignore 16 | fs.readFile.mockReturnValue('test') 17 | 18 | const image = await adapter.fetch('/foo/bar') 19 | expect(image?.toString()).toBe('test') 20 | 21 | expect(fs.readFile).toHaveBeenCalledWith('/tmp/foo/bar') 22 | }) 23 | 24 | it('returns undefined if the image does not exist', async () => { 25 | mocked(fs.readFile).mockImplementation(() => { 26 | const error = new Error('ohoh') as any 27 | error.code = 'ENOENT' 28 | throw error 29 | }) 30 | 31 | expect(await adapter.fetch('/foo/bar')).toBeUndefined() 32 | }) 33 | 34 | it('re-throws other HTTP errors', async () => { 35 | // @ts-ignore 36 | mocked(fs.readFile).mockImplementation(() => { 37 | const error = new Error('ohoh') as NodeJS.ErrnoException 38 | error.code = 'any other' 39 | throw error 40 | }) 41 | 42 | await expect(() => adapter.fetch('/foo/bar')).rejects.toThrow( 43 | expect.objectContaining({ code: 'any other' }), 44 | ) 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/adapter/fs.adapter.ts: -------------------------------------------------------------------------------- 1 | import { ImageAdapter } from '../interfaces' 2 | import { promises as fs } from 'fs' 3 | import { join } from 'path' 4 | import { getLogger } from '../logger' 5 | 6 | export class FsAdapter implements ImageAdapter { 7 | private log = getLogger('adapter:fs') 8 | 9 | constructor(public rootPath: string) { 10 | this.log(`Using rootPath: ${rootPath}`) 11 | } 12 | 13 | async fetch(path: string): Promise { 14 | const imagePath = join(this.rootPath, path) 15 | this.log(`Fetching: ${imagePath}`) 16 | try { 17 | return await fs.readFile(imagePath) 18 | } catch (error) { 19 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 20 | return undefined 21 | } 22 | 23 | throw error 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/adapter/http.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import { HttpAdapter } from './http.adapter' 3 | 4 | jest.mock('got') 5 | 6 | describe('HttpAdapter', () => { 7 | let adapter: HttpAdapter 8 | beforeEach(() => { 9 | adapter = new HttpAdapter({ prefixUrl: 'http://example.com/foo' }) 10 | }) 11 | 12 | test('constructor()', () => { 13 | expect(got.extend).toHaveBeenCalledWith({ 14 | prefixUrl: 'http://example.com/foo', 15 | }) 16 | }) 17 | 18 | describe('fetch()', () => { 19 | it('returns the image', async () => { 20 | const image = await adapter.fetch('/foo/bar') 21 | expect(image?.toString()).toBe('test') 22 | 23 | // @ts-ignore 24 | expect(adapter.client.get).toHaveBeenCalledWith('/foo/bar', { 25 | responseType: 'buffer', 26 | }) 27 | }) 28 | 29 | it('returns undefined on 404', async () => { 30 | const error = new Error('ohoh') as any 31 | error.response = { statusCode: 404 } 32 | 33 | // @ts-ignore 34 | adapter.client.get.mockImplementation(() => { 35 | throw error 36 | }) 37 | 38 | expect(await adapter.fetch('/foo/bar')).toBeUndefined() 39 | }) 40 | 41 | it('re-throws other HTTP errors', async () => { 42 | // @ts-ignore 43 | adapter.client.get.mockImplementation(() => { 44 | const error = new Error('ohoh') as any 45 | error.response = { statusCode: 500 } 46 | throw error 47 | }) 48 | 49 | await expect(() => adapter.fetch('/foo/bar')).rejects.toThrow( 50 | expect.objectContaining({ 51 | response: { statusCode: 500 }, 52 | }), 53 | ) 54 | }) 55 | 56 | it('re-throws other errors', async () => { 57 | // @ts-ignore 58 | adapter.client.get.mockImplementation(() => { 59 | throw new Error('ohoh') 60 | }) 61 | 62 | await expect(() => adapter.fetch('/foo/bar')).rejects.toThrow('ohoh') 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/adapter/http.adapter.ts: -------------------------------------------------------------------------------- 1 | import type { ExtendOptions, Got, RequestError } from 'got' 2 | import { ImageAdapter } from '../interfaces' 3 | import { getLogger } from '../logger' 4 | import { optionalRequire } from '../optional-require' 5 | 6 | export class HttpAdapter implements ImageAdapter { 7 | private client: Got 8 | private log = getLogger('adapter:http') 9 | 10 | constructor(gotOptions: ExtendOptions) { 11 | const got = optionalRequire<{ default: Got }>('got').default 12 | 13 | this.client = got.extend({ 14 | ...gotOptions, 15 | }) 16 | 17 | this.log(`Using prefixUrl: ${this.getPrefixUrl()}`) 18 | } 19 | 20 | private getPrefixUrl() { 21 | return this.client.defaults.options.prefixUrl 22 | } 23 | 24 | async fetch(url: string): Promise { 25 | this.log(`Fetching: ${this.getPrefixUrl()}${url}`) 26 | try { 27 | const response = await this.client.get(url, { 28 | responseType: 'buffer', 29 | }) 30 | return response.body 31 | } catch (error) { 32 | // eslint-disable-next-line no-magic-numbers 33 | if ((error as RequestError).response?.statusCode === 404) { 34 | return undefined 35 | } 36 | 37 | throw error 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http.adapter' 2 | export * from './fs.adapter' 3 | export * from './s3.adapter' 4 | -------------------------------------------------------------------------------- /src/adapter/s3.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { S3Adapter } from './s3.adapter' 2 | import { container } from 'tsyringe' 3 | 4 | const awsPromiseMock = jest 5 | .fn() 6 | .mockReturnValue({ Body: Buffer.from('mocked') }) 7 | const getObjectMock = jest.fn().mockReturnValue({ promise: awsPromiseMock }) 8 | 9 | jest.mock('aws-sdk', () => { 10 | class S3Mock { 11 | getObject = getObjectMock 12 | } 13 | 14 | return { S3: S3Mock } 15 | }) 16 | 17 | describe('S3Adapter', () => { 18 | let adapter: S3Adapter 19 | 20 | beforeEach(() => { 21 | adapter = new S3Adapter('my-bucket') 22 | }) 23 | 24 | afterEach(() => { 25 | container.clearInstances() 26 | }) 27 | 28 | describe('fetch()', () => { 29 | it('fetches the image', async () => { 30 | expect(await adapter.fetch('foo')).toEqual(Buffer.from('mocked')) 31 | 32 | expect(getObjectMock).toHaveBeenCalledWith({ 33 | Bucket: 'my-bucket', 34 | Key: 'foo', 35 | }) 36 | }) 37 | 38 | it('does not find the image', async () => { 39 | awsPromiseMock.mockReturnValue({ Body: undefined }) 40 | 41 | expect(await adapter.fetch('foo')).toBeUndefined() 42 | }) 43 | 44 | it('catches errors', async () => { 45 | awsPromiseMock.mockRejectedValueOnce(new Error('ohoh')) 46 | // @ts-ignore 47 | const logSpy = jest.spyOn(adapter, 'log') 48 | 49 | expect(await adapter.fetch('foo')).toBeUndefined() 50 | expect(logSpy).toHaveBeenCalledWith('Fetching bucket "foo" failed: {}') 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/adapter/s3.adapter.ts: -------------------------------------------------------------------------------- 1 | import type { S3 as S3Type } from 'aws-sdk' 2 | import { ImageAdapter, Type } from '../interfaces' 3 | import { getLogger } from '../logger' 4 | import { optionalRequire } from '../optional-require' 5 | 6 | export class S3Adapter implements ImageAdapter { 7 | private log = getLogger('adapter:s3') 8 | 9 | constructor( 10 | public readonly bucketName: string, 11 | private readonly s3client?: S3Type, 12 | ) { 13 | const { S3 } = optionalRequire<{ S3: Type }>('aws-sdk') 14 | this.s3client ??= new S3() 15 | 16 | this.log(`Using bucket name: ${bucketName}`) 17 | } 18 | 19 | async fetch(id: string): Promise { 20 | this.log(`Fetching image "${id}" from bucket "${this.bucketName}"`) 21 | try { 22 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 23 | const object = await this.s3client!.getObject({ 24 | Bucket: this.bucketName, 25 | Key: id, 26 | }).promise() 27 | 28 | if (!Buffer.isBuffer(object.Body)) { 29 | return undefined 30 | } 31 | 32 | return object.Body 33 | } catch (error) { 34 | this.log(`Fetching bucket "${id}" failed: ${JSON.stringify(error)}`) 35 | return undefined 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/cached-image.test.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import { CachedImage } from './cached-image' 3 | import { ImageAdapter } from './interfaces' 4 | 5 | class ImageAdapterMock implements ImageAdapter { 6 | fetchMock: Buffer | undefined = undefined 7 | 8 | // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars 9 | async fetch(id: string): Promise { 10 | return this.fetchMock 11 | } 12 | } 13 | 14 | describe('CachedImage', () => { 15 | let cachedImage: CachedImage 16 | let adapter: ImageAdapterMock 17 | let cache: Map 18 | 19 | beforeEach(() => { 20 | adapter = new ImageAdapterMock() 21 | cache = new Map() 22 | 23 | cachedImage = new CachedImage(new Keyv({ store: cache })) 24 | }) 25 | 26 | describe('fetch()', () => { 27 | it('stores the image in the cache', async () => { 28 | expect(await cachedImage.fetch('abc', adapter)).toBeUndefined() 29 | 30 | adapter.fetchMock = Buffer.from('foo') 31 | expect((await cachedImage.fetch('def', adapter))?.toString()).toBe('foo') 32 | 33 | expect(cache).toMatchInlineSnapshot(` 34 | Map { 35 | "keyv:image:def" => "{\\"value\\":\\":base64:Zm9v\\",\\"expires\\":null}", 36 | } 37 | `) 38 | }) 39 | 40 | it('serves the image from cache', async () => { 41 | // @ts-ignore 42 | await cachedImage.cache.set('image:foo', 'bar') 43 | 44 | expect((await cachedImage.fetch('foo', adapter))?.toString()).toBe('bar') 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/cached-image.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import { ImageAdapter } from './interfaces' 3 | import { getLogger } from './logger' 4 | import { singleton } from 'tsyringe' 5 | 6 | @singleton() 7 | export class CachedImage { 8 | log = getLogger('cached-image') 9 | 10 | constructor(private readonly cache: Keyv) {} 11 | 12 | async fetch(id: string, adapter: ImageAdapter): Promise { 13 | const cacheKey = `image:${id}` 14 | 15 | let image = await this.cache.get(cacheKey) 16 | 17 | if (image) { 18 | this.log(`Serving original image ${cacheKey} from cache ...`) 19 | return image 20 | } 21 | 22 | image = await adapter.fetch(id) 23 | 24 | if (image) { 25 | this.log(`Caching original image ${id} ...`) 26 | await this.cache.set(cacheKey, image) 27 | } 28 | 29 | return image 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config.service.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from './config.service' 2 | import { container } from 'tsyringe' 3 | 4 | describe('ConfigService', () => { 5 | let config: ConfigService 6 | 7 | beforeEach(() => { 8 | config = container.resolve(ConfigService) 9 | }) 10 | 11 | afterEach(() => { 12 | container.clearInstances() 13 | }) 14 | 15 | describe('get()', () => { 16 | it('reads config var from env', () => { 17 | process.env.EXPRESS_SHARP_foo = 'baz' 18 | 19 | expect(config.get('foo')).toBe('baz') 20 | expect(config.get('foo', 'bar')).toBe('baz') 21 | 22 | delete process.env.EXPRESS_SHARP_foo 23 | }) 24 | 25 | it('unknown keys are undefined', () => { 26 | expect(config.get('foo')).toBeUndefined() 27 | }) 28 | 29 | it('supports a default value', () => { 30 | expect(config.get('foo', 'bar')).toBe('bar') 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/config.service.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { singleton } from 'tsyringe' 3 | import { camelToSnake } from './util' 4 | 5 | @singleton() 6 | export class ConfigService { 7 | static GLOBAL_PREFIX = 'EXPRESS_SHARP_' 8 | 9 | private readonly config: { [name: string]: string | undefined } = { 10 | ...config().parsed, 11 | } 12 | 13 | private getConfig() { 14 | return { ...this.config, ...process.env } 15 | } 16 | 17 | private expand(name: string) { 18 | return ( 19 | ConfigService.GLOBAL_PREFIX + 20 | (name.includes('.') 21 | ? camelToSnake(name).split('.').join('_').toUpperCase() 22 | : name) 23 | ) 24 | } 25 | 26 | get(name: string, defaultValue: string): string 27 | get(name: string): string | undefined 28 | get(name: string, defaultValue?: string): string | undefined { 29 | const key = this.expand(name) 30 | const config = this.getConfig() 31 | 32 | return key in config ? config[key] : defaultValue 33 | } 34 | 35 | set(name: string, value: string): void { 36 | this.config[this.expand(name)] = value 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/decorators.test.ts: -------------------------------------------------------------------------------- 1 | import { Transform, ToNumber } from './decorators' 2 | 3 | describe('Transform', () => { 4 | it('transforms properties', () => { 5 | class Test { 6 | @Transform(Number) 7 | foo!: string 8 | 9 | bar = '200' 10 | 11 | @ToNumber() 12 | baz!: any 13 | 14 | constructor(args: Partial) { 15 | Object.assign(this, args) 16 | } 17 | } 18 | 19 | const test = new Test({ bar: '300', foo: '100' }) 20 | expect(test).toEqual({ bar: '300', foo: 100 }) 21 | 22 | const test2 = new Test({ bar: '300', baz: '200', foo: '1100' }) 23 | expect(test2).toEqual({ bar: '300', baz: 200, foo: 1100 }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | function makePropertyMapper( 4 | prototype: unknown, 5 | key: string, 6 | mapper: (value: U) => T, 7 | ) { 8 | Object.defineProperty(prototype, key, { 9 | enumerable: true, 10 | set(value: U) { 11 | Object.defineProperty(this, key, { 12 | enumerable: true, 13 | get() { 14 | return Reflect.getMetadata(key, this) as T 15 | }, 16 | set(value: U) { 17 | Reflect.defineMetadata(key, mapper(value), this) 18 | }, 19 | }) 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 22 | this[key] = value 23 | }, 24 | }) 25 | } 26 | 27 | export function Transform(transformer: (value: U) => T) { 28 | return function (target: unknown, key: string): void { 29 | makePropertyMapper(target, key, transformer) 30 | } 31 | } 32 | 33 | export function ToNumber() { 34 | return function (target: unknown, key: string): void { 35 | makePropertyMapper(target, key, Number) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/express-sharp-client.test.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from './express-sharp-client' 2 | 3 | describe('ExpressSharpClient', () => { 4 | test('createClient()', () => { 5 | const client = createClient( 6 | 'https://example.com/my-express-sharp-endpoint', 7 | 'test', 8 | ) 9 | 10 | expect(client.url('/foo.png', { width: 500 })).toBe( 11 | 'https://example.com/my-express-sharp-endpoint/foo.png?w=500&s=Of3ty8QY-NDhCsIrgIHvPvbokkDcxV8KtaYUB4NFRd8', 12 | ) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/express-sharp-client.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe' 2 | import { ImageUrl } from './image-url.service' 3 | import { ConfigService } from './config.service' 4 | 5 | export function createClient(endpoint: string, secret?: string): ImageUrl { 6 | const clientContainer = container.createChildContainer() 7 | clientContainer.register('endpoint', { useValue: endpoint }) 8 | 9 | if (secret) { 10 | clientContainer.resolve(ConfigService).set('signedUrl.secret', secret) 11 | } 12 | 13 | return clientContainer.resolve(ImageUrl) 14 | } 15 | -------------------------------------------------------------------------------- /src/http-exception.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpException, 3 | BadRequestException, 4 | NotFoundException, 5 | ForbiddenException, 6 | } from './http-exception' 7 | 8 | describe('HttpExceptioin', () => { 9 | test('HttpException', () => { 10 | const exception = new HttpException(406, 'test') 11 | expect(exception.status).toBe(406) 12 | expect(exception.message).toBe('test') 13 | }) 14 | 15 | test('BadRequestException', () => { 16 | const exception = new BadRequestException('test') 17 | expect(exception.status).toBe(400) 18 | expect(exception.message).toBe('test') 19 | }) 20 | 21 | test('NotFoundException', () => { 22 | const exception = new NotFoundException('test') 23 | expect(exception.status).toBe(404) 24 | expect(exception.message).toBe('test') 25 | }) 26 | 27 | test('ForbiddenException', () => { 28 | const exception = new ForbiddenException('test') 29 | expect(exception.status).toBe(403) 30 | expect(exception.message).toBe('test') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/http-exception.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | 3 | export class HttpException extends Error { 4 | constructor(public readonly status: number, message: string) { 5 | super(message) 6 | Error.captureStackTrace(this, HttpException) 7 | } 8 | } 9 | 10 | export class BadRequestException extends HttpException { 11 | constructor(message: string) { 12 | super(400, message) 13 | Error.captureStackTrace(this, BadRequestException) 14 | } 15 | } 16 | 17 | export class NotFoundException extends HttpException { 18 | constructor(message: string) { 19 | super(404, message) 20 | Error.captureStackTrace(this, NotFoundException) 21 | } 22 | } 23 | 24 | export class ForbiddenException extends HttpException { 25 | constructor(message: string) { 26 | super(403, message) 27 | Error.captureStackTrace(this, ForbiddenException) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/image-url.service.test.ts: -------------------------------------------------------------------------------- 1 | import { ImageUrl } from './image-url.service' 2 | import { container } from 'tsyringe' 3 | import { ConfigService } from './config.service' 4 | 5 | describe('ImaegUrl', () => { 6 | let url: ImageUrl 7 | 8 | beforeEach(() => { 9 | container.register('endpoint', { useValue: 'http://example.com/base' }) 10 | url = container.resolve(ImageUrl) 11 | }) 12 | 13 | afterEach(() => { 14 | container.clearInstances() 15 | }) 16 | 17 | describe('buildUrl()', () => { 18 | it('builds a valid image url', () => { 19 | expect(url.url('/bla/fasel.jpg', { height: 300, width: 100 })).toBe( 20 | 'http://example.com/base/bla/fasel.jpg?h=300&w=100', 21 | ) 22 | }) 23 | 24 | it('signes URLs', () => { 25 | container.resolve(ConfigService).set('signedUrl.secret', 'foo') 26 | 27 | expect(url.url('/bla/fasel.jpg', { height: 300, width: 100 })).toBe( 28 | 'http://example.com/base/bla/fasel.jpg?h=300&w=100&s=xE2D9Z8Q7DqksFiHeJSyJqGsnbKXTU8jcs-ucS8KdTc', 29 | ) 30 | }) 31 | 32 | it('filters undefined values', () => { 33 | expect(url.url('/bla/fasel.jpg', { height: undefined, width: 100 })).toBe( 34 | 'http://example.com/base/bla/fasel.jpg?w=100', 35 | ) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/image-url.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'tsyringe' 2 | import { URL } from 'url' 3 | import { ConfigService } from './config.service' 4 | import { QueryParams, Signer } from './interfaces' 5 | import { ResizeDto } from './resize.dto' 6 | import { UrlSigner } from './signed-url.service' 7 | 8 | @injectable() 9 | export class ImageUrl { 10 | constructor( 11 | @inject('endpoint') private readonly endpoint: string, 12 | @inject(UrlSigner) private readonly urlSigner: Signer, 13 | private readonly config: ConfigService, 14 | ) {} 15 | 16 | private _buildUrl( 17 | imageId: string, 18 | params: Partial>, 19 | ): URL { 20 | const url = new URL(this.endpoint) 21 | 22 | // Endpoint w/ search params not supported 23 | url.search = '' 24 | 25 | url.pathname += imageId 26 | url.pathname = url.pathname.replace(/\/\/+/, '') 27 | 28 | Object.entries(params) 29 | .filter(([, value]) => value !== undefined) 30 | .sort() 31 | .forEach(([name, value]) => { 32 | url.searchParams.set( 33 | QueryParams[name as Exclude], 34 | 35 | // Type Guard in .filter() does not work: 36 | // > A type predicate cannot reference element 'value' in a binding 37 | // > pattern. 38 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 39 | value.toString(), 40 | ) 41 | }) 42 | 43 | if (this.config.get('signedUrl.secret')) { 44 | this.urlSigner.sign(url) 45 | } 46 | 47 | return url 48 | } 49 | 50 | url(imageId: string, params: Partial>): string { 51 | return this._buildUrl(imageId, params).toString() 52 | } 53 | 54 | pathQuery(imageId: string, params: Partial>): string { 55 | const url = this._buildUrl(imageId, params) 56 | return url.pathname + url.search 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | export * from './adapter' 4 | export * from './express-sharp-client' 5 | export * from './interfaces' 6 | export * from './middleware/express-sharp.middleware' 7 | export * from './transformer.service' 8 | export * from './http-exception' 9 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { CorsOptions } from 'cors' 2 | import Keyv from 'keyv' 3 | 4 | export type format = 'heif' | 'jpeg' | 'jpg' | 'png' | 'raw' | 'tiff' | 'webp' 5 | 6 | export interface Result { 7 | // format: 'heic' | 'heif' | 'jpeg' | 'jpg' | 'png' | 'raw' | 'tiff' | 'webp' 8 | format: format | undefined 9 | image: Buffer | null 10 | } 11 | 12 | export interface ImageAdapter { 13 | fetch(id: string): Promise 14 | } 15 | 16 | export interface ExpressSharpOptions { 17 | autoUseWebp?: boolean 18 | cors?: CorsOptions 19 | imageAdapter: ImageAdapter 20 | cache?: Keyv 21 | secret?: string 22 | } 23 | 24 | export enum QueryParams { 25 | quality = 'q', 26 | width = 'w', 27 | height = 'h', 28 | format = 'f', 29 | progressive = 'p', 30 | crop = 'c', 31 | trim = 't', 32 | gravity = 'g', 33 | } 34 | 35 | export interface Signer { 36 | sign(string: string | URL): string 37 | verify(string: string): boolean 38 | } 39 | 40 | export interface Type extends Function { 41 | new (...args: unknown[]): T 42 | } 43 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | 3 | export const log = debug('express-sharp') 4 | 5 | export function getLogger(ns: string): debug.Debugger { 6 | return log.extend(ns) 7 | } 8 | -------------------------------------------------------------------------------- /src/middleware/etag-caching.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { etagCaching } from './etag-caching.middleware' 3 | 4 | describe('etagCaching()', () => { 5 | let next: jest.Mock 6 | let response: Response 7 | 8 | beforeEach(() => { 9 | next = jest.fn() 10 | 11 | // @ts-ignore 12 | response = { 13 | locals: { dto: {} }, 14 | sendStatus: jest.fn(), 15 | setHeader: jest.fn(), 16 | } 17 | }) 18 | 19 | it('sends a 304 status', () => { 20 | // @ts-ignore 21 | etagCaching({ fresh: true }, response, next) 22 | 23 | expect(response.setHeader).toHaveBeenCalledWith( 24 | 'ETag', 25 | 'W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"', 26 | ) 27 | expect(next).not.toHaveBeenCalled() 28 | expect(response.sendStatus).toHaveBeenCalledWith(304) 29 | }) 30 | 31 | it('does not send 304', () => { 32 | // @ts-ignore 33 | etagCaching({ fresh: false }, response, next) 34 | 35 | expect(response.setHeader).toHaveBeenCalledWith( 36 | 'ETag', 37 | 'W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"', 38 | ) 39 | expect(next).toHaveBeenCalled() 40 | expect(response.sendStatus).not.toHaveBeenCalled() 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/middleware/etag-caching.middleware.ts: -------------------------------------------------------------------------------- 1 | import etag from 'etag' 2 | import { Request, Response, NextFunction } from 'express' 3 | 4 | export function etagCaching( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction, 8 | ): void { 9 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 10 | res.setHeader('ETag', etag(JSON.stringify(res.locals.dto), { weak: true })) 11 | 12 | if (!req.fresh) { 13 | next() 14 | return 15 | } 16 | 17 | // eslint-disable-next-line no-magic-numbers 18 | res.sendStatus(304) 19 | } 20 | -------------------------------------------------------------------------------- /src/middleware/express-sharp.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import Keyv from 'keyv' 3 | import { container } from 'tsyringe' 4 | import { HttpAdapter } from '../adapter' 5 | import { ConfigService } from '../config.service' 6 | import { Transformer } from '../transformer.service' 7 | import { expressSharp } from './express-sharp.middleware' 8 | import { signedUrl } from './signed-url.middleware' 9 | import { useWebpIfSupported } from './use-webp-if-supported.middleware' 10 | 11 | function getMiddlewaresFromRouter(router: Router): any[] { 12 | return router.stack[0].route.stack.map((layer: any) => layer.handle) 13 | } 14 | 15 | describe('expressSharp', () => { 16 | container.resolve(ConfigService).set('signedUrl.secret', 'test') 17 | container.register('imageAdapter', { useValue: {} }) 18 | container.register(Keyv, { useValue: new Keyv() }) 19 | 20 | const next = jest.fn() 21 | const transformer = container.resolve(Transformer) 22 | const transformSpy = jest.spyOn(transformer, 'transform').mockImplementation() 23 | 24 | afterEach(() => { 25 | next.mockClear() 26 | transformSpy.mockClear() 27 | }) 28 | 29 | describe('expressSharp()', () => { 30 | const imageAdapter = new HttpAdapter({}) 31 | 32 | describe('signed url', () => { 33 | let router: Router 34 | 35 | beforeEach(() => { 36 | router = expressSharp({ imageAdapter, secret: 'foo' }) 37 | }) 38 | 39 | it('sets the secret', () => { 40 | expect(container.resolve(ConfigService).get('signedUrl.secret')).toBe( 41 | 'foo', 42 | ) 43 | }) 44 | 45 | it('uses the signed url feature', () => { 46 | expect(getMiddlewaresFromRouter(router)).toContain(signedUrl) 47 | }) 48 | }) 49 | 50 | describe('auto-use webp', () => { 51 | it('is used', () => { 52 | const router = expressSharp({ autoUseWebp: true, imageAdapter }) 53 | expect(getMiddlewaresFromRouter(router)).toContain(useWebpIfSupported) 54 | }) 55 | 56 | it('is not used', () => { 57 | const router = expressSharp({ autoUseWebp: false, imageAdapter }) 58 | expect(getMiddlewaresFromRouter(router)).not.toContain( 59 | useWebpIfSupported, 60 | ) 61 | }) 62 | }) 63 | 64 | describe('Cache', () => { 65 | it('uses the cache instance', () => { 66 | const cache = new Keyv({ namespace: 'foo' }) 67 | 68 | expressSharp({ cache, imageAdapter }) 69 | expect(container.resolve(Keyv)).toBe(cache) 70 | }) 71 | 72 | it('uses the default cache', () => { 73 | expressSharp({ imageAdapter }) 74 | expect(container.resolve(Keyv)).toBeDefined() 75 | }) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/middleware/express-sharp.middleware.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | import { 3 | NextFunction, 4 | Request, 5 | RequestHandler, 6 | Response, 7 | Router, 8 | } from 'express' 9 | import Keyv from 'keyv' 10 | import { container } from 'tsyringe' 11 | import { ConfigService } from '../config.service' 12 | import { ExpressSharpOptions } from '../interfaces' 13 | import { ResizeDto } from '../resize.dto' 14 | import { etagCaching } from './etag-caching.middleware' 15 | import { signedUrl } from './signed-url.middleware' 16 | import { transformImage } from './transform-image.middleware' 17 | import { transformQueryParams } from './transform-query-params.middleware' 18 | import { useWebpIfSupported } from './use-webp-if-supported.middleware' 19 | import { validate } from './validator.middleware' 20 | 21 | function extractActiveMiddlewares( 22 | middlewaresDefinitions: [RequestHandler, boolean?][], 23 | ): RequestHandler[] { 24 | return middlewaresDefinitions 25 | .filter(([, active]) => active ?? true) 26 | .map(([middleware]) => middleware) 27 | } 28 | 29 | export function expressSharp(options: ExpressSharpOptions): Router { 30 | const configService = container.resolve(ConfigService) 31 | 32 | if (options.secret) { 33 | configService.set('signedUrl.secret', options.secret) 34 | } 35 | 36 | container.register(Keyv, { useValue: options.cache || new Keyv() }) 37 | 38 | const middlewares = extractActiveMiddlewares([ 39 | [ 40 | (req: Request, res: Response, next: NextFunction) => { 41 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 42 | res.locals.imageAdapter = options.imageAdapter 43 | next() 44 | }, 45 | ], 46 | [transformQueryParams], 47 | [validate(ResizeDto)], 48 | [useWebpIfSupported, options.autoUseWebp ?? true], 49 | [cors(options.cors)], 50 | [signedUrl, configService.get('signedUrl.secret') !== undefined], 51 | [etagCaching], 52 | [transformImage], 53 | ]) 54 | 55 | const router = Router() 56 | router.get('/:url(*)', ...middlewares) 57 | return router 58 | } 59 | -------------------------------------------------------------------------------- /src/middleware/signed-url.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction } from 'express' 2 | import { signedUrl } from './signed-url.middleware' 3 | import { container } from 'tsyringe' 4 | import { ConfigService } from '../config.service' 5 | import { ForbiddenException } from '../http-exception' 6 | 7 | describe('signedUrl()', () => { 8 | let next: jest.Mock 9 | 10 | beforeAll(() => { 11 | container.resolve(ConfigService).set('signedUrl.secret', 'test') 12 | }) 13 | 14 | beforeEach(() => { 15 | next = jest.fn() 16 | }) 17 | 18 | it('accepts valid signatures', () => { 19 | const request = { 20 | get() { 21 | return 'example.com' 22 | }, 23 | originalUrl: '/foo?s=5llwo-ByfwrHXVIfMv-c6VRF4D8c9891t4tJ1oitcC8', 24 | protocol: 'https', 25 | } 26 | 27 | // @ts-ignore 28 | signedUrl(request, {}, next) 29 | 30 | expect(next).toHaveBeenCalledWith() 31 | }) 32 | 33 | it('throws a ForbiddenException if the signature is invalid', () => { 34 | const request = { 35 | get() { 36 | return 'example.com' 37 | }, 38 | originalUrl: '/foo?s=invalid', 39 | protocol: 'https', 40 | } 41 | 42 | // @ts-ignore 43 | signedUrl(request, {}, next) 44 | 45 | expect(next).toHaveBeenCalledWith( 46 | new ForbiddenException('Invalid signature'), 47 | ) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/middleware/signed-url.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { container } from 'tsyringe' 3 | import { ForbiddenException } from '../http-exception' 4 | import { UrlSigner } from '../signed-url.service' 5 | 6 | export function signedUrl( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction, 10 | ): void { 11 | const signer = container.resolve(UrlSigner) 12 | 13 | if ( 14 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 15 | signer.verify(`${req.protocol}://${req.get('host')!}${req.originalUrl}`) 16 | ) { 17 | next() 18 | return 19 | } 20 | 21 | next(new ForbiddenException('Invalid signature')) 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware/transform-image.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import { container } from 'tsyringe' 3 | import { Transformer } from '../transformer.service' 4 | import { transformImage } from './transform-image.middleware' 5 | 6 | describe('transformImage', () => { 7 | container.registerInstance('imageAdapter', { useValue: null }) 8 | container.register(Keyv, { useValue: new Keyv() }) 9 | 10 | const next = jest.fn() 11 | const transformer = container.resolve(Transformer) 12 | const transformSpy = jest.spyOn(transformer, 'transform').mockImplementation() 13 | 14 | afterEach(() => { 15 | next.mockClear() 16 | transformSpy.mockClear() 17 | }) 18 | 19 | it('renders the next middleware (aka 404) if no image is returned', async () => { 20 | const response = { 21 | locals: { dto: { url: 'http://example.com/foo.png' } }, 22 | } 23 | 24 | transformSpy.mockResolvedValue({ format: 'jpeg', image: null }) 25 | 26 | // @ts-ignore 27 | await transformImage({}, response, next) 28 | 29 | expect(next).toHaveBeenCalledWith() 30 | }) 31 | 32 | it('sends the transformed image', async () => { 33 | const response = { 34 | locals: { dto: { url: 'http://example.com/foo.png' } }, 35 | send: jest.fn(), 36 | type: jest.fn(), 37 | } 38 | 39 | const image = Buffer.from('image mock') 40 | 41 | transformSpy.mockResolvedValue({ format: 'jpeg', image }) 42 | 43 | // @ts-ignore 44 | await transformImage({}, response, next) 45 | 46 | expect(next).not.toHaveBeenCalled() 47 | expect(response.type).toHaveBeenCalledWith('image/jpeg') 48 | expect(response.send).toHaveBeenCalledWith(image) 49 | }) 50 | 51 | it('calls the next error middleware on error', async () => { 52 | transformSpy.mockImplementation(() => { 53 | throw new Error('ohoh') 54 | }) 55 | 56 | await transformImage( 57 | // @ts-ignore 58 | {}, 59 | { locals: { dto: { url: 'http://example.com/foo.png' } } }, 60 | next, 61 | ) 62 | 63 | expect(next).toHaveBeenCalledWith(new Error('ohoh')) 64 | }) 65 | 66 | it('throws an error if the image url is missing', async () => { 67 | await transformImage( 68 | // @ts-ignore 69 | {}, 70 | { locals: { dto: {} } }, 71 | next, 72 | ) 73 | 74 | expect(next).toHaveBeenCalledWith(new Error('Image url missing')) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/middleware/transform-image.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { container } from 'tsyringe' 3 | import { ImageAdapter } from '../interfaces' 4 | import { ResizeDto } from '../resize.dto' 5 | import { Transformer } from '../transformer.service' 6 | 7 | export async function transformImage( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | ): Promise { 12 | const { dto, imageAdapter } = res.locals as { 13 | dto: ResizeDto 14 | imageAdapter: ImageAdapter 15 | } 16 | 17 | try { 18 | const transformer = container.resolve(Transformer) 19 | 20 | if (!dto.url) { 21 | throw new Error('Image url missing') 22 | } 23 | const { format, image } = await transformer.transform( 24 | dto.url, 25 | dto, 26 | imageAdapter, 27 | ) 28 | 29 | if (!image || !format) { 30 | next() 31 | return 32 | } 33 | 34 | // TODO: Cache-Control, Last-Modified 35 | res.type(`image/${format}`) 36 | res.send(image) 37 | } catch (error) { 38 | next(error) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/middleware/transform-query-params.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { QueryParams } from '../interfaces' 3 | 4 | export function transformQueryParams( 5 | req: Request, 6 | _res: Response, 7 | next: NextFunction, 8 | ): void { 9 | Object.entries(QueryParams) 10 | .filter(([, shortName]: [string, string]) => shortName in req.query) 11 | .forEach(([name, shortName]) => { 12 | req.query[name] = req.query[shortName] 13 | delete req.query[shortName] 14 | }) 15 | 16 | next() 17 | } 18 | -------------------------------------------------------------------------------- /src/middleware/use-webp-if-supported.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { useWebpIfSupported } from './use-webp-if-supported.middleware' 2 | import { NextFunction, Response } from 'express' 3 | 4 | describe('useWebpIfSupported()', () => { 5 | let next: jest.Mock 6 | let response: Response 7 | 8 | beforeEach(() => { 9 | next = jest.fn() 10 | 11 | // @ts-ignore 12 | response = { locals: { dto: {} } } 13 | }) 14 | 15 | it('sets the format to webp', () => { 16 | // @ts-ignore 17 | useWebpIfSupported({ headers: { accept: 'image/webp' } }, response, next) 18 | expect(response.locals.dto.format).toBe('webp') 19 | }) 20 | 21 | it('does not change the image format', () => { 22 | // @ts-ignore 23 | useWebpIfSupported({ headers: { accept: 'image/other' } }, response, next) 24 | expect(response.locals.dto.format).toBeUndefined() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/middleware/use-webp-if-supported.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { ResizeDto } from '../resize.dto' 3 | 4 | export function useWebpIfSupported( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction, 8 | ): void { 9 | const { dto } = res.locals as { dto: ResizeDto } 10 | 11 | if (req.headers.accept?.includes('image/webp')) { 12 | dto.format = 'webp' 13 | } 14 | 15 | next() 16 | } 17 | -------------------------------------------------------------------------------- /src/middleware/validator.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import * as ClassValidator from 'class-validator' 2 | import { IsDefined, IsOptional, Min } from 'class-validator' 3 | import { NextFunction } from 'express' 4 | import { ToNumber } from '../decorators' 5 | import { BadRequestException, HttpException } from '../http-exception' 6 | import { validate } from './validator.middleware' 7 | 8 | class TestDto { 9 | @IsDefined() 10 | foo?: string 11 | 12 | @ToNumber() 13 | @IsOptional() 14 | @Min(10) 15 | bar?: number 16 | 17 | constructor(data: Partial) { 18 | Object.assign(this, data) 19 | } 20 | } 21 | 22 | describe('Validator', () => { 23 | let next: jest.Mock 24 | const handler = validate(TestDto) 25 | 26 | beforeEach(() => { 27 | next = jest.fn() 28 | }) 29 | 30 | async function handle(query: any = {}, params: any = {}) { 31 | await handler( 32 | // @ts-ignore 33 | { 34 | params, 35 | query, 36 | }, 37 | { locals: {} }, 38 | next, 39 | ) 40 | } 41 | 42 | it('throws a BadRequestException if the check does not pass', async () => { 43 | await handle() 44 | 45 | const exception = next.mock.calls[0][0] as HttpException 46 | expect(exception).toBeInstanceOf(BadRequestException) 47 | expect(exception.status).toBe(400) 48 | }) 49 | 50 | it('throws an exception if required perperties are missing', async () => { 51 | await handle() 52 | 53 | expect(next).toHaveBeenCalledWith( 54 | new BadRequestException('foo should not be null or undefined'), 55 | ) 56 | }) 57 | 58 | it('does not throw an exception if required properties are passed', async () => { 59 | await handle({ foo: '' }) 60 | 61 | expect(next).toHaveBeenCalledWith() 62 | }) 63 | 64 | test('`error.constraints` might be missing', async () => { 65 | const validateSpy = jest 66 | .spyOn(ClassValidator, 'validate') 67 | .mockImplementation() 68 | .mockResolvedValue([{ children: [], property: 'bla' }]) 69 | 70 | await handle({}) 71 | 72 | expect(next).toHaveBeenCalledWith(new BadRequestException('Unknown error')) 73 | 74 | validateSpy.mockRestore() 75 | }) 76 | 77 | test('class-validator decorators work', async () => { 78 | await handle({ bar: '1', foo: '' }) 79 | 80 | expect(next).toHaveBeenCalledWith( 81 | new BadRequestException('bar must not be less than 10'), 82 | ) 83 | }) 84 | 85 | it('catches all errors', async () => { 86 | const assignSpy = jest.spyOn(Object, 'assign').mockImplementation(() => { 87 | throw new Error('ohoh') 88 | }) 89 | 90 | await handle({ foo: '' }) 91 | expect(next).toHaveBeenCalledWith(new Error('ohoh')) 92 | 93 | assignSpy.mockRestore() 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/middleware/validator.middleware.ts: -------------------------------------------------------------------------------- 1 | import { validate as validate_, ValidationError } from 'class-validator' 2 | import { NextFunction, Request, Response } from 'express' 3 | import { BadRequestException } from '../http-exception' 4 | 5 | // eslint-disable-next-line @typescript-eslint/ban-types 6 | export function validate(Dto: { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | new (...args: any[]): T 9 | }): (req: Request, res: Response, next: NextFunction) => Promise { 10 | return async (req: Request, res: Response, next: NextFunction) => { 11 | try { 12 | const dto = new Dto({ ...req.query, ...req.params }) 13 | 14 | const errors: ValidationError[] = await validate_(dto, { 15 | forbidUnknownValues: true, 16 | }) 17 | 18 | if (errors.length > 0) { 19 | const message = errors 20 | .map((error: ValidationError) => 21 | Object.values(error.constraints ?? {}), 22 | ) 23 | .join(', ') 24 | 25 | next(new BadRequestException(message || 'Unknown error')) 26 | } else { 27 | ;(res.locals as { dto: T }).dto = dto 28 | next() 29 | } 30 | } catch (error) { 31 | next(error) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/object-hash.service.test.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe' 2 | import { ConfigService } from './config.service' 3 | import { ToNumber, Transform } from './decorators' 4 | import { ObjectHash } from './object-hash.service' 5 | 6 | describe('ObjectHash', () => { 7 | let config: ConfigService 8 | let hasher: ObjectHash 9 | 10 | beforeEach(() => { 11 | config = container.resolve(ConfigService) 12 | hasher = container.resolve(ObjectHash) 13 | }) 14 | 15 | afterEach(() => { 16 | container.clearInstances() 17 | }) 18 | 19 | describe('stringify()', () => { 20 | it('should sort properties', () => { 21 | expect(hasher.stringify({ a: 'a', z: 'z' })).toBe('{"a":"a","z":"z"}') 22 | }) 23 | 24 | it('should stringify all enumerable properties', () => { 25 | const object = { bar: undefined, c: 'c' } 26 | Object.defineProperties(object, { 27 | bar: { 28 | enumerable: true, 29 | get: () => 'bar', 30 | }, 31 | foo: { 32 | get: () => 'foo', 33 | }, 34 | }) 35 | 36 | expect(hasher.stringify(object)).toBe('{"bar":"bar","c":"c"}') 37 | }) 38 | 39 | it('should stringify classes', () => { 40 | class Foo { 41 | foo = 'bar' 42 | } 43 | const foo = new Foo() 44 | Object.defineProperty(foo, 'bar', { 45 | enumerable: true, 46 | get: () => 'bar', 47 | }) 48 | 49 | expect(hasher.stringify(foo)).toBe('{"bar":"bar","foo":"bar"}') 50 | }) 51 | 52 | it('should stringify decorated/transformed class properties', () => { 53 | class Foo { 54 | @Transform(Number) 55 | foo?: number 56 | 57 | @Transform(Number) 58 | bar = 10 59 | 60 | @ToNumber() 61 | baz? = '30' 62 | 63 | c = 'c' 64 | } 65 | 66 | const foo = new Foo() 67 | // @ts-ignore 68 | foo.foo = '20' 69 | 70 | expect(hasher.stringify(foo)).toBe('{"bar":10,"baz":30,"c":"c","foo":20}') 71 | }) 72 | }) 73 | 74 | describe('hash()', () => { 75 | it('should create a default length hash', () => { 76 | expect(hasher.hash({ foo: true })).toBe('ce35fd691f') 77 | }) 78 | 79 | it('should create a 5 chars hash', () => { 80 | config.set('objectHash.hashLength', '5') 81 | expect(hasher.hash({ foo: true })).toHaveLength(5) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/object-hash.service.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { injectable } from 'tsyringe' 3 | import { ConfigService } from './config.service' 4 | 5 | @injectable() 6 | export class ObjectHash { 7 | constructor(private readonly config: ConfigService) {} 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 10 | stringify(object: any): string { 11 | return JSON.stringify(object, Object.keys(object).sort()) 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 15 | hash(object: any): string { 16 | return crypto 17 | .createHash(this.config.get('objectHash.alogorithm', 'sha256')) 18 | .update(this.stringify(object)) 19 | .digest('hex') 20 | .slice(0, parseInt(this.config.get('objectHash.hashLength', '10'), 10)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/optional-require.ts: -------------------------------------------------------------------------------- 1 | export function optionalRequire( 2 | packageName: string, 3 | // eslint-disable-next-line @typescript-eslint/ban-types 4 | ): T { 5 | try { 6 | // eslint-disable-next-line security/detect-non-literal-require, @typescript-eslint/no-var-requires 7 | return require(packageName) as unknown as T 8 | } catch { 9 | return {} as unknown as T 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/resize.dto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 2 | /* eslint-disable no-magic-numbers */ 3 | import { 4 | IsBoolean, 5 | IsIn, 6 | IsInt, 7 | IsNumber, 8 | IsOptional, 9 | IsString, 10 | Max, 11 | Min, 12 | } from 'class-validator' 13 | import 'reflect-metadata' 14 | import { GravityEnum } from 'sharp' 15 | import { Transform } from './decorators' 16 | import { format } from './interfaces' 17 | import { IsUrl } from './validator/is-url' 18 | 19 | export class ResizeDto { 20 | constructor(args: Partial) { 21 | Object.assign(this, args) 22 | } 23 | 24 | @IsOptional() 25 | @IsIn(['heif', 'jpeg', 'jpg', 'png', 'raw', 'tiff', 'webp']) 26 | @IsString() 27 | public format?: format 28 | 29 | @Transform(Number) 30 | @IsOptional() 31 | @IsNumber({ allowInfinity: false, allowNaN: false }) 32 | @IsInt() 33 | @Min(1) 34 | @Max(10_000) 35 | public height?: number 36 | 37 | @Transform(Number) 38 | @IsNumber() 39 | @Min(1) 40 | @Max(10_000) 41 | public width: number = 500 42 | 43 | @Transform(Number) 44 | @IsInt() 45 | @Min(0) 46 | @Max(100) 47 | public quality: number = 80 48 | 49 | @Transform((value) => value === 'true') 50 | @IsBoolean() 51 | public progressive: boolean = false 52 | 53 | @Transform((value) => value === 'true') 54 | @IsBoolean() 55 | public crop: boolean = false 56 | 57 | @Transform((value) => value === 'true') 58 | @IsBoolean() 59 | public trim: boolean = false 60 | 61 | @IsIn([ 62 | 'north', 63 | 'northeast', 64 | 'southeast', 65 | 'south', 66 | 'southwest', 67 | 'west', 68 | 'northwest', 69 | 'east', 70 | 'center', 71 | 'centre', 72 | ]) 73 | @IsOptional() 74 | public gravity?: keyof GravityEnum 75 | 76 | @IsUrl({ message: 'Invalid image url' }) 77 | public url?: string 78 | } 79 | -------------------------------------------------------------------------------- /src/signed-url.service.test.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe' 2 | import { ConfigService } from './config.service' 3 | import { UrlSigner } from './signed-url.service' 4 | 5 | describe('URLSigner', () => { 6 | let config: ConfigService 7 | let signer: UrlSigner 8 | 9 | beforeEach(() => { 10 | config = container.resolve(ConfigService) 11 | signer = container.resolve(UrlSigner) 12 | }) 13 | 14 | afterEach(() => { 15 | container.clearInstances() 16 | }) 17 | 18 | test('sign()', () => { 19 | config.set('signedUrl.secret', 'foo') 20 | config.set('signedUrl.paramName', 'bar') 21 | 22 | expect(signer.sign('https://example.com/?foo=bar')).toBe( 23 | 'https://example.com/?foo=bar&bar=MCv4YIAKZv0bxQBmTnwGrU6GjT8bRUmsW9rhVtkMyIk', 24 | ) 25 | }) 26 | 27 | describe('verify()', () => { 28 | beforeEach(() => { 29 | config.set('signedUrl.secret', 'foo') 30 | config.set('signedUrl.paramName', 'bar') 31 | }) 32 | 33 | it('detects a valid signature', () => { 34 | expect( 35 | signer.verify( 36 | 'https://example.com/?foo=bar&bar=MCv4YIAKZv0bxQBmTnwGrU6GjT8bRUmsW9rhVtkMyIk', 37 | ), 38 | ).toBeTruthy() 39 | }) 40 | 41 | it('detects an invalid signature', () => { 42 | expect( 43 | signer.verify( 44 | 'https://example.com/?foo=bar&additional-param&bar=MCv4YIAKZv0bxQBmTnwGrU6GjT8bRUmsW9rhVtkMyIk', 45 | ), 46 | ).toBeFalsy() 47 | }) 48 | 49 | it('accepts an URL object', () => { 50 | expect(() => { 51 | signer.verify(new URL('https://example.com/foo.png')) 52 | }).not.toThrow() 53 | }) 54 | 55 | it('throws an error if no secret is configured', () => { 56 | config.set('signedUrl.secret', '') 57 | expect(() => { 58 | signer.verify('https://example.com/?foo=bar&bar=whatever') 59 | }).toThrow( 60 | new Error( 61 | 'Secret is missing. Please set EXPRESS_SHARP_SIGNED_URL_SECRET', 62 | ), 63 | ) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/signed-url.service.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { injectable } from 'tsyringe' 3 | import { URL } from 'url' 4 | import { ConfigService } from './config.service' 5 | 6 | @injectable() 7 | export class UrlSigner { 8 | constructor(private readonly config: ConfigService) {} 9 | 10 | private makeUrlSafe(string: string) { 11 | return string.replace(/[+/_]/g, '-').replace(/=/g, '') 12 | } 13 | 14 | private getSignature(string: string): string { 15 | const secret = this.config.get('signedUrl.secret') 16 | if (!secret) { 17 | throw new TypeError( 18 | `Secret is missing. Please set ${ConfigService.GLOBAL_PREFIX}SIGNED_URL_SECRET`, 19 | ) 20 | } 21 | 22 | return this.makeUrlSafe( 23 | crypto 24 | .createHmac(this.config.get('signedUrl.algorithm', 'sha256'), secret) 25 | .update(string) 26 | .digest('base64'), 27 | ) 28 | } 29 | 30 | private getParamName(): string { 31 | return this.config.get('signedUrl.paramName', 's') 32 | } 33 | 34 | sign(url: string | URL): string { 35 | if (typeof url === 'string') { 36 | url = new URL(url) 37 | } 38 | 39 | url.searchParams.set(this.getParamName(), this.getSignature(url.toString())) 40 | return url.toString() 41 | } 42 | 43 | verify(url: string | URL): boolean { 44 | if (typeof url === 'string') { 45 | url = new URL(url) 46 | } 47 | 48 | const signature = url.searchParams.get(this.getParamName()) 49 | url.searchParams.delete(this.getParamName()) 50 | 51 | return this.getSignature(url.toString()) === signature 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/transformer.service.test.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import { container } from 'tsyringe' 3 | import { ImageAdapter } from './interfaces' 4 | import { ObjectHash } from './object-hash.service' 5 | import { Transformer } from './transformer.service' 6 | import { CachedImage } from './cached-image' 7 | 8 | class ImageMockAdapter implements ImageAdapter { 9 | // eslint-disable-next-line @typescript-eslint/require-await 10 | async fetch(id: string): Promise { 11 | return Buffer.from(`image: ${id}`) 12 | } 13 | } 14 | 15 | describe('Transformer', () => { 16 | let transformer: Transformer 17 | 18 | beforeEach(() => { 19 | const cache = new Keyv() 20 | transformer = new Transformer( 21 | container.resolve(ObjectHash), 22 | cache, 23 | new CachedImage(cache), 24 | ) 25 | }) 26 | 27 | describe('getCropDimensions()', () => { 28 | test('width <= maxSize && height <= maxSize', () => { 29 | expect(transformer.getCropDimensions(300, 100, 200)).toEqual([100, 200]) 30 | }) 31 | 32 | test('width > height', () => { 33 | expect(transformer.getCropDimensions(100, 300, 200)).toEqual([100, 67]) 34 | }) 35 | 36 | test('other cases', () => { 37 | expect(transformer.getCropDimensions(100, 300, 400)).toEqual([75, 100]) 38 | }) 39 | 40 | test('height defaults to width', () => { 41 | expect(transformer.getCropDimensions(200, 300)).toEqual([200, 200]) 42 | }) 43 | }) 44 | 45 | describe('transform()', () => { 46 | it('throws an exception if the format can not be determined', async () => { 47 | await expect( 48 | // @ts-ignore 49 | () => transformer.transform('foo', {}, new ImageMockAdapter()), 50 | ).rejects.toThrow( 51 | new Error('Input buffer contains unsupported image format'), 52 | ) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/transformer.service.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import sharp from 'sharp' 3 | import { singleton } from 'tsyringe' 4 | import { CachedImage } from './cached-image' 5 | import { format, ImageAdapter, Result } from './interfaces' 6 | import { getLogger } from './logger' 7 | import { ObjectHash } from './object-hash.service' 8 | import { ResizeDto } from './resize.dto' 9 | 10 | const DEFAULT_CROP_MAX_SIZE = 2000 11 | 12 | @singleton() 13 | export class Transformer { 14 | log = getLogger('transformer') 15 | cropMaxSize = DEFAULT_CROP_MAX_SIZE 16 | 17 | constructor( 18 | private readonly objectHasher: ObjectHash, 19 | private readonly cache: Keyv, 20 | private readonly cachedOriginalImage: CachedImage, 21 | ) {} 22 | 23 | getCropDimensions(maxSize: number, width: number, height?: number): number[] { 24 | height = height || width 25 | 26 | if (width <= maxSize && height <= maxSize) { 27 | return [width, height] 28 | } 29 | 30 | const aspectRatio = width / height 31 | 32 | if (width > height) { 33 | return [maxSize, Math.round(maxSize / aspectRatio)] 34 | } 35 | 36 | return [maxSize * aspectRatio, maxSize].map((number) => Math.round(number)) 37 | } 38 | 39 | buildCacheKey(id: string, options: ResizeDto, adapterName: string): string { 40 | const hash = this.objectHasher.hash(options) 41 | return `transform:${id}:${adapterName}:${hash}` 42 | } 43 | 44 | async transform( 45 | id: string, 46 | options: ResizeDto, 47 | imageAdapter: ImageAdapter, 48 | ): Promise { 49 | const cacheKey = this.buildCacheKey( 50 | id, 51 | options, 52 | imageAdapter.constructor.name, 53 | ) 54 | 55 | const cachedImage = await this.cache.get(cacheKey) 56 | if (cachedImage) { 57 | this.log(`Serving ${id} from cache ...`) 58 | return cachedImage 59 | } 60 | 61 | this.log(`Resizing ${id} with options:`, JSON.stringify(options)) 62 | 63 | const originalImage = await this.cachedOriginalImage.fetch(id, imageAdapter) 64 | 65 | if (!originalImage) { 66 | return { 67 | format: options.format, 68 | // eslint-disable-next-line unicorn/no-null 69 | image: null, 70 | } 71 | } 72 | 73 | const transformer = sharp(originalImage).rotate() 74 | 75 | if (!options.format) { 76 | options.format = (await transformer.metadata()).format as format 77 | } 78 | 79 | if (options.trim) { 80 | transformer.trim() 81 | } 82 | 83 | if (options.crop) { 84 | const [cropWidth, cropHeight] = this.getCropDimensions( 85 | this.cropMaxSize, 86 | options.width, 87 | options.height, 88 | ) 89 | transformer.resize(cropWidth, cropHeight, { 90 | position: options.gravity, 91 | }) 92 | } else { 93 | transformer.resize(options.width, options.height, { 94 | fit: 'inside', 95 | withoutEnlargement: true, 96 | }) 97 | } 98 | 99 | const image = await transformer 100 | .toFormat(options.format, { 101 | progressive: options.progressive, 102 | quality: options.quality, 103 | }) 104 | .toBuffer() 105 | 106 | this.log('Resizing done') 107 | 108 | const result = { format: options.format, image } 109 | 110 | this.log(`Caching ${cacheKey} ...`) 111 | await this.cache.set(cacheKey, result) 112 | return result 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { camelToSnake } from './util' 2 | 3 | test('camelToSnake()', () => { 4 | expect(camelToSnake('fooBar_Baz')).toBe('foo_bar__baz') 5 | }) 6 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function camelToSnake(string: string): string { 2 | return string 3 | .replace(/\w([A-Z])/g, (m) => { 4 | return `${m[0]}_${m[1]}` 5 | }) 6 | .toLowerCase() 7 | } 8 | -------------------------------------------------------------------------------- /src/validator/is-url.test.ts: -------------------------------------------------------------------------------- 1 | import { IsUrlConstraint } from './is-url' 2 | 3 | describe('IsUrl', () => { 4 | let urlConstraint: IsUrlConstraint 5 | 6 | beforeEach(() => { 7 | urlConstraint = new IsUrlConstraint() 8 | }) 9 | 10 | test('validate()', () => { 11 | expect(urlConstraint.validate('')).toBeFalsy() 12 | // @ts-ignore 13 | expect(urlConstraint.validate()).toBeFalsy() 14 | // @ts-ignore 15 | expect(urlConstraint.validate(null)).toBeFalsy() 16 | // @ts-ignore 17 | expect(urlConstraint.validate(undefined)).toBeFalsy() 18 | expect(urlConstraint.validate('\t')).toBeFalsy() 19 | expect(urlConstraint.validate('\\')).toBeFalsy() 20 | expect(urlConstraint.validate('../')).toBeFalsy() 21 | 22 | expect(urlConstraint.validate('/foo/bar.html')).toBeTruthy() 23 | expect(urlConstraint.validate('/foo/bar.html?a=b')).toBeTruthy() 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/validator/is-url.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | } from 'class-validator' 7 | import { URL } from 'url' 8 | 9 | @ValidatorConstraint() 10 | export class IsUrlConstraint implements ValidatorConstraintInterface { 11 | validate(url: string): boolean { 12 | if (!url) { 13 | return false 14 | } 15 | 16 | // `url` is an absolute URI without host and protocol. Validating it by 17 | // by using any base URL 18 | const parsedUrl = new URL(url, 'https://example.com') 19 | 20 | return !/^\/+$/.test(parsedUrl.pathname) 21 | } 22 | } 23 | 24 | export function IsUrl(validationOptions?: ValidationOptions) { 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 26 | return function (object: any, propertyName: string): void { 27 | registerDecorator({ 28 | constraints: [], 29 | 30 | options: validationOptions, 31 | 32 | propertyName: propertyName, 33 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 34 | target: object.constructor, 35 | validator: IsUrlConstraint, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "**/*.test.ts", 6 | "**/vendors/**", 7 | "**/__mocks__/**", 8 | "**/test/**", 9 | "**/__tests__/**", 10 | "example/**" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "incremental": true, 10 | "module": "CommonJS", 11 | "moduleResolution": "Node", 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "outDir": "./dist", 15 | "sourceMap": true, 16 | "strict": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "strictPropertyInitialization": true, 20 | "target": "ES2019" 21 | }, 22 | "include": ["src/**/*", "__tests__/**/*", "example/**/*", "__mocks__/**/*"] 23 | } 24 | --------------------------------------------------------------------------------