├── .dockerignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .snyk ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── __tests__ ├── app.test.js ├── config.test.js └── utils.test.js ├── app.js ├── app.json ├── config ├── axios.js ├── config.example.js ├── index.js ├── server.example.js └── swagger.js ├── controllers ├── apiController.js └── mainController.js ├── docker-compose.yml ├── docs ├── _config.yml ├── docker.md ├── install_heroku.md ├── install_ubuntu.md └── log_rotation.md ├── package.json ├── routes └── index.js ├── schemas └── index.js ├── utils └── index.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # node-imgoptimize 2 | small_img 3 | source_img 4 | node_modules 5 | coverage 6 | 7 | # WebStrom 8 | .idea/ 9 | package-lock.json 10 | 11 | .git 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard", 4 | "plugin:jest/recommended" 5 | ], 6 | "plugins": ["jest"], 7 | "rules": { 8 | "no-console": "off" 9 | }, 10 | "env": { 11 | "node": true, 12 | "jest/globals": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [AntonLukichev] 2 | # patreon: AntonLukichev 3 | # open_collective: anton-lukichev 4 | # ko_fi: Replace with a single Ko-fi username 5 | # tidelift: Replace with a single Tidelift platform-name/package-name e.g., npm/babel 6 | custom: https://money.yandex.ru/to/410011912585514 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | 3 | Please answer the following questions for yourself before submitting an issue. 4 | 5 | **YOU MAY DELETE THE PREREQUISITES SECTION.** 6 | 7 | - [ ] I am running the latest version 8 | - [ ] I checked the documentation and found no answer 9 | - [ ] I checked to make sure that this issue has not already been filed 10 | - [ ] I'm reporting the issue to the correct repository (for multi-repository projects) 11 | 12 | ## Expected Behavior 13 | 14 | Please describe the behavior you are expecting 15 | 16 | ## Current Behavior 17 | 18 | What is the current behavior? 19 | 20 | ## Failure Information (for bugs) 21 | 22 | Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. 23 | 24 | ### Steps to Reproduce 25 | 26 | Please provide detailed steps for reproducing the issue. 27 | 28 | 1. step 1 29 | 2. step 2 30 | 3. you get it... 31 | 32 | ### Context 33 | 34 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. 35 | 36 | - Node.js Version: 37 | - OS: 38 | 39 | ### Failure Logs 40 | 41 | Please include any relevant log snippets or files here. 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] This change requires a documentation update 13 | 14 | \* _please delete options that are not relevant._ 15 | 16 | # How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. 19 | 20 | **Test Configuration**: 21 | 22 | - Node.js version: 23 | - OS: 24 | 25 | # Checklist: 26 | 27 | - [ ] My code follows the style guidelines of this project 28 | - [ ] I have performed a self-review of my own code 29 | - [ ] I have commented my code, particularly in hard-to-understand areas 30 | - [ ] I have made corresponding changes to the documentation 31 | - [ ] My changes generate no new warnings 32 | - [ ] I have added tests that prove my fix is effective or that my feature works 33 | - [ ] New and existing unit tests pass locally with my changes 34 | - [ ] Any dependent changes have been merged and published in downstream modules 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node-imgoptimize 2 | small_img 3 | source_img 4 | config/config.js 5 | config/server.js 6 | favicon.ico 7 | 8 | # WebStrom 9 | .idea/ 10 | #package-lock.json 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # Dependency directories 32 | node_modules/ 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional eslint cache 38 | .eslintcache 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # Yarn Integrity file 47 | .yarn-integrity 48 | 49 | # dotenv environment variables file 50 | .env 51 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-AXIOS-174505: 7 | - axios: 8 | patched: '2019-05-06T08:56:05.891Z' 9 | SNYK-JS-LODASH-450202: 10 | - snyk > snyk-nodejs-lockfile-parser > lodash: 11 | patched: '2019-07-04T03:28:33.664Z' 12 | - snyk > lodash: 13 | patched: '2019-07-04T03:28:33.664Z' 14 | - snyk > snyk-nuget-plugin > lodash: 15 | patched: '2019-07-04T03:28:33.664Z' 16 | - snyk > @snyk/dep-graph > lodash: 17 | patched: '2019-07-04T03:28:33.664Z' 18 | - snyk > inquirer > lodash: 19 | patched: '2019-07-04T03:28:33.664Z' 20 | - snyk > snyk-config > lodash: 21 | patched: '2019-07-04T03:28:33.664Z' 22 | - snyk > snyk-mvn-plugin > lodash: 23 | patched: '2019-07-04T03:28:33.664Z' 24 | - snyk > snyk-go-plugin > graphlib > lodash: 25 | patched: '2019-07-04T03:28:33.664Z' 26 | - snyk > snyk-nodejs-lockfile-parser > graphlib > lodash: 27 | patched: '2019-07-04T03:28:33.664Z' 28 | - snyk > snyk-php-plugin > @snyk/composer-lockfile-parser > lodash: 29 | patched: '2019-07-04T03:28:33.664Z' 30 | - snyk > @snyk/dep-graph > graphlib > lodash: 31 | patched: '2019-07-04T03:28:33.664Z' 32 | SNYK-JS-HTTPSPROXYAGENT-469131: 33 | - '@sentry/node > https-proxy-agent': 34 | patched: '2019-10-04T06:31:13.665Z' 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | notifications: 5 | # email: false 6 | email: 7 | recipients: 8 | - anton@lukichev.pro 9 | on_success: never # default: change 10 | on_failure: always # default: always 11 | env: 12 | global: 13 | - CC_TEST_REPORTER_ID=8931f8388c66c011327c53ead7d6bad4b31b43185513c00619408440471585ee 14 | before_install: 15 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 16 | - chmod +x ./cc-test-reporter 17 | - ./cc-test-reporter before-build 18 | - rm -rf node_modules 19 | cache: yarn 20 | branches: 21 | only: 22 | - master 23 | - /^greenkeeper/.*$/ 24 | after_script: 25 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | - The code is written in ES6 using [Javascript Standard Style](http://standardjs.com/). 4 | - Please have PRs be single-purpose and try to stick to the coding style that the plugin uses. 5 | - Keep new features easily testable. 6 | - Documentation PRs are more than welcome! 7 | - I'm always looking for people to help maintain this package, if you want to get involved shoot me an email at [anton@lukichev.pro](mailto:anton@lukichev.pro)! 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | # FROM node:carbon 3 | ENV \ 4 | TERM="xterm-256color"\ 5 | PLATFORM="linuxmusl-x64"\ 6 | NODE_ENV="production" 7 | 8 | LABEL maintainer="anton@lukichev.pro" 9 | 10 | RUN npm install -g nodemon yarn 11 | 12 | WORKDIR /app 13 | 14 | COPY package.json . 15 | 16 | RUN yarn install 17 | ENV PATH /app/node_modules/.bin:$PATH 18 | 19 | COPY . . 20 | 21 | CMD [ "nodemon", "app.js" ] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Anton Lukichev 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node app.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-imgoptimize 2 | 3 | [![Build Status](https://img.shields.io/travis/AntonLukichev/node-imgoptimize/master.svg?style=flat-square)](https://travis-ci.org/AntonLukichev/node-imgoptimize) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/96d7439c49523ea13e1e/maintainability)](https://codeclimate.com/github/AntonLukichev/node-imgoptimize/maintainability) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/96d7439c49523ea13e1e/test_coverage)](https://codeclimate.com/github/AntonLukichev/node-imgoptimize/test_coverage) 6 | 7 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](http://standardjs.com/) 8 | ![](https://img.shields.io/node/v/node-imgoptimize/latest.svg?style=flat-square) 9 | [![License](https://img.shields.io/npm/l/fastify.svg?style=flat-square)](LICENSE) 10 | 11 | [![release](https://img.shields.io/github/release/AntonLukichev/node-imgoptimize.svg?style=flat-square)](https://github.com/AntonLukichev/node-imgoptimize/releases) 12 | [![NPM downloads](https://img.shields.io/npm/dm/node-imgoptimize.svg?style=flat)](https://www.npmjs.com/package/node-imgoptimize) 13 | [![Known Vulnerabilities](https://snyk.io/test/github/AntonLukichev/node-imgoptimize/badge.svg?targetFile=package.json&style=flat-square)](https://snyk.io/test/github/AntonLukichev/node-imgoptimize?targetFile=package.json) 14 | [![Greenkeeper badge](https://badges.greenkeeper.io/AntonLukichev/node-imgoptimize.svg?style=flat-square)](https://greenkeeper.io/) 15 | 16 | Proxy server for image optimization on Node.JS use (fastify, axios, sharp) 17 | Automatic recognition of browser support formats WebP 18 | 19 | ## Install 20 | 21 | ### Yarn 22 | 23 | ```bash 24 | yarn add node-imgoptimize 25 | ``` 26 | 27 | ```bash 28 | git clone https://github.com/AntonLukichev/node-imgoptimize.git 29 | yarn install 30 | ``` 31 | 32 | ### NPM 33 | 34 | ```bash 35 | npm install node-imgoptimize --save 36 | ``` 37 | 38 | ```bash 39 | git clone https://github.com/AntonLukichev/node-imgoptimize.git 40 | npm install 41 | ``` 42 | 43 | Requires node >= 8.0, but I recommended use >= 10.0 LTS 44 | 45 | [Install as service on Ubuntu](docs/install_ubuntu.md)
46 | 47 | ## Heroku 48 | 49 | example https://node-imgoptimize.herokuapp.com/ 50 | 51 | [Install on Heroku](docs/install_heroku.md)
52 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/AntonLukichev/node-imgoptimize) 53 | 54 | ## use Docker 55 | 56 | ```bash 57 | $ git clone https://github.com/AntonLukichev/node-imgoptimize.git 58 | $ cd ./node-imgoptimize 59 | $ docker build -t node-imgoptimize . 60 | $ docker run -it --rm -p 3000:3000 -e NODE_ENV=production node-imgoptimize 61 | ``` 62 | 63 | ```bash 64 | $ git clone https://github.com/AntonLukichev/node-imgoptimize.git 65 | $ cd ./node-imgoptimize 66 | $ docker-compose build 67 | $ docker-compose up 68 | ``` 69 | 70 | see tips for [Docker](docs/docker.md) 71 | 72 | ## Example Usage 73 | 74 | ``` 75 | {url}?w=500&q=80 76 | ``` 77 | 78 | support parameters (after "?"): 79 | 80 | **w** - image width;
81 | **h** - image height;
82 | **q** - image quality, 80 recommended for JPEG and WebP;
83 | **fm** - image format, list in config.js and default jpeg or webp (if browser supports it);
84 | 85 | ## Example config 86 | 87 | Edit defaults config for you need (automatically created after the first run) 88 | 89 | ``` 90 | ./config/config.js 91 | 92 | ./config/server.js 93 | 94 | ``` 95 | 96 | ## ToDo 97 | 98 | v0.2.0: 99 | 100 | - [x] generate source url with original request parameters 101 | - [x] caching original file 102 | - [x] support a large number of files 103 | 104 | v0.3.0: 105 | 106 | - [x] add multiple path URI 107 | - [x] add JPEG and WebP options 108 | 109 | v0.4.0: 110 | 111 | - [x] custom log level 112 | - [x] documentation API in Swagger 113 | - [x] add docker 114 | - [x] support Heroku 115 | 116 | v0.5.0: 117 | 118 | - [x] add monitoring errors sentry.io 119 | - [x] default favicon 120 | - [x] add tests 121 | 122 | v1.0.0: 123 | 124 | - [ ] migrate Typescript and NestJS 125 | 126 | I plan to implement in the future: 127 | 128 | - add CORS 129 | - expand API 130 | - add options Low Quality Image Placeholders (LQIP) 131 | - add Client Hints (headers DPR, Viewport-Width, Width) for support Chrome, Opera, Android Chrome 132 | - add support another formats (GIF, PNG, SVG...) 133 | - divide the functionality into modules up to version 1.0.0 134 | - support PAAS (~~Heroku~~, Zeit, Nanobox...) 135 | - add support HTTP2 136 | - add security protection 137 | - add support redis/mongo for cache info 138 | - add image operations (rotate, blur, normalise...) 139 | 140 | ## Lazy loading 141 | 142 | If you'd like to lazy load images, I recommend using [lazysizes](https://github.com/aFarkas/lazysizes). 143 | 144 | ## FAQ 145 | 146 | ### How add custom favicon 147 | 148 | Simple copy your favicon.ico file in root directory project 149 | 150 | ## Security 151 | 152 | [Node.js Security Checklist](https://blog.risingstack.com/node-js-security-checklist/) 153 | 154 | [Production Best Practices: Security](https://expressjs.com/en/advanced/best-practice-security.html) 155 | 156 | ## Contributing 157 | 158 | [See the CONTRIBUTING file here](CONTRIBUTING.md) 159 | 160 | ## License 161 | 162 | [MIT](LICENSE) 163 | 164 | Copyright (c) [Anton Lukichev](https://github.com/AntonLukichev) 165 | -------------------------------------------------------------------------------- /__tests__/app.test.js: -------------------------------------------------------------------------------- 1 | const fastify = require('../app') 2 | const fastifyInject = (method = 'GET', url = '/') => { 3 | const response = fastify.inject({ 4 | method: method, 5 | url: url 6 | }) 7 | return response 8 | } 9 | 10 | describe('Server tests', () => { 11 | afterAll(() => { 12 | fastify.close() 13 | }) 14 | 15 | test('OK /', async (done) => { 16 | const response = await fastifyInject() 17 | expect(response.statusCode).toBe(200) 18 | expect(response.payload).toBe('{"server":"ok"}') 19 | done() 20 | }) 21 | test('OK /favicon.ico', async (done) => { 22 | const response = await fastifyInject('GET', '/favicon.ico') 23 | expect(response.statusCode).toBe(200) 24 | done() 25 | }) 26 | test('OK /api/headers/', async (done) => { 27 | const response = await fastify.inject({ 28 | method: 'GET', 29 | url: '/api/headers/' 30 | }) 31 | expect(response.statusCode).toBe(200) 32 | done() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /__tests__/config.test.js: -------------------------------------------------------------------------------- 1 | const mainController = require('../controllers/mainController') 2 | const CONFIG = require('../config') 3 | 4 | describe('MainController tests', () => { 5 | test('Accept WEBP', async (done) => { 6 | const acceptString = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3' 7 | expect(mainController.isAcceptWebp(acceptString)).toBe(true) 8 | done() 9 | }) 10 | test('getFormat WEBP', async (done) => { 11 | expect(mainController.getFormat('webp')).toBe('webp') 12 | done() 13 | }) 14 | test('getFormat JPEG', async (done) => { 15 | expect(mainController.getFormat('jpeg')).toBe('jpeg') 16 | done() 17 | }) 18 | test('getFormat default', async (done) => { 19 | expect(mainController.getFormat('raw')).toBe(CONFIG.defaultFormat) 20 | done() 21 | }) 22 | test('isAllowFileType true', async (done) => { 23 | expect(mainController.isAllowFileType('image/jpeg')).toBe(true) 24 | done() 25 | }) 26 | test('isAllowFileType false', async (done) => { 27 | expect(mainController.isAllowFileType('image/raw')).toBe(false) 28 | done() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | describe('Utils tests', () => { 6 | test('cpSync file exists', async (done) => { 7 | const src = path.join(__dirname, '../config/config.example.js') 8 | const dest = path.join(__dirname, '../config/config.js') 9 | expect(utils.cpSync(src, dest)).toBe(false) 10 | done() 11 | }) 12 | test('cpSync file copy', async (done) => { 13 | const src = path.join(__dirname, '../config/config.example.js') 14 | const dest = path.join(__dirname, '../config/config.temp.js') 15 | expect(utils.cpSync(src, dest)).toBe(true) 16 | fs.unlinkSync(dest) 17 | done() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nodejs 2 | const routes = require('./routes') 3 | const CONFIG = require('./config') 4 | const swagger = require('./config/swagger') 5 | const mainController = require('./controllers/mainController') 6 | const fastify = require('fastify')({ logger: { level: CONFIG.logLevel } }) 7 | if (CONFIG.sentryDsn) { 8 | const Sentry = require('@sentry/node') 9 | let sentryDebug = false 10 | if (process.env.NODE_ENV === 'development') sentryDebug = true 11 | Sentry.init({ 12 | dsn: CONFIG.sentryDsn, 13 | release: `${process.env.npm_package_name}@${process.env.npm_package_version}`, 14 | debug: sentryDebug, 15 | serverName: process.env.COMPUTERNAME 16 | }) 17 | fastify.setErrorHandler((err, req, reply) => { 18 | Sentry.withScope(scope => { 19 | scope.setUser({ 20 | ip_address: req.raw.ip 21 | }) 22 | scope.setTag('path', req.raw.url) 23 | scope.addEventProcessor(event => Sentry.Handlers.parseRequest(event, req)) 24 | Sentry.captureException(err) 25 | }) 26 | }) 27 | } 28 | // fastify.register(require('fastify-response-time')) error fastify-response-time\index.js:60 Cannot convert undefined or null to object 29 | fastify.register(require('fastify-favicon')) 30 | fastify.register(require('fastify-static'), { root: __dirname }) 31 | fastify.register(require('fastify-swagger'), swagger.options) 32 | 33 | const startCheck = () => { 34 | try { 35 | mainController.createFolder(CONFIG.sourceFolder) 36 | mainController.createFolder(CONFIG.destinationFolder) 37 | } catch (e) { 38 | fastify.log.error('can\'t create folder from config', e) 39 | process.exit(1) 40 | } 41 | } 42 | startCheck() 43 | 44 | routes.forEach((route) => { 45 | fastify.route(route) 46 | }) 47 | 48 | process.on('SIGINT', async () => { 49 | console.log('stopping fastify server') 50 | await fastify.close() 51 | process.exit(0) 52 | }) 53 | 54 | fastify.ready(err => { 55 | if (err) throw err 56 | fastify.swagger() 57 | }) 58 | 59 | const start = async () => { 60 | try { 61 | await fastify.listen(process.env.PORT || CONFIG.httpPort, CONFIG.httpHost, (err, address) => { 62 | if (err) { 63 | console.error(err) 64 | } else { 65 | console.log(`Server listening on ${address}`) 66 | } 67 | }) 68 | } catch (err) { 69 | console.log('Error starting server:', err) 70 | process.exit(1) 71 | } 72 | } 73 | 74 | start() 75 | 76 | module.exports = fastify 77 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-imgoptimize", 3 | "description": "Proxy server for image optimization on Node.JS", 4 | "repository": "https://github.com/AntonLukichev/node-imgoptimize", 5 | "logo": "https://rawgit.com/heroku/node-js-sample/master/public/node.svg", 6 | "keywords": ["node", "node-js", "image-processing", "fastify", "axios", "sharp", "libvips"] 7 | } 8 | -------------------------------------------------------------------------------- /config/axios.js: -------------------------------------------------------------------------------- 1 | exports.axiosConfig = { 2 | timeout: 1000 * 10, 3 | method: 'get', 4 | responseType: 'stream', 5 | withCredentials: false, 6 | maxContentLength: 1024 * 1024 * 20, // 20 Mb 7 | maxRedirects: 5 8 | } 9 | -------------------------------------------------------------------------------- /config/config.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sourceFolder: 'source_img', 3 | destinationFolder: 'small_img', 4 | baseURL: 'https://images.unsplash.com', 5 | pathURI: [ 6 | '/photo' 7 | ], 8 | width: null, 9 | height: null, 10 | quality: 80, 11 | format: 'jpeg', 12 | fit: 'cover', 13 | allowFormat: [ 14 | 'jpeg', 15 | 'webp' 16 | ], 17 | jpegOptions: { 18 | progressive: true, // use progressive (interlace) scan 19 | chromaSubsampling: '4:2:0', // set to '4:4:4' to prevent chroma subsampling when quality <= 90 (optional, default '4:2:0') 20 | trellisQuantisation: false, // apply trellis quantisation 21 | overshootDeringing: false, // apply overshoot deringing 22 | optimiseScans: true, // optimise progressive scans, forces progressive, requires mozjpeg 23 | optimiseCoding: true, // optimise Huffman coding tables 24 | quantisationTable: 0 // quantization table to use, integer 0-8 25 | }, 26 | webpOptions: { 27 | alphaQuality: 100, // quality of alpha layer, integer 0-100 28 | lossless: false, // use lossless compression mode 29 | nearLossless: false // use near_lossless compression mode 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils') 2 | 3 | utils.cpSync('./config/config.example.js', './config/config.js') 4 | utils.cpSync('./config/server.example.js', './config/server.js') 5 | 6 | const server = require('./server') 7 | const config = require('./config') 8 | const axios = require('./axios') 9 | module.exports = { 10 | httpHost: server.httpHost, 11 | httpPort: server.httpPort, 12 | logLevel: server.logLevel, 13 | baseURL: config.baseURL, 14 | pathURI: config.pathURI, 15 | defaultWidth: config.width, 16 | defaultHeight: config.height, 17 | defaultQuality: config.quality, 18 | defaultFormat: config.format, 19 | defaultFit: config.fit, 20 | sourceFolder: config.sourceFolder, 21 | destinationFolder: config.destinationFolder, 22 | allowFormat: config.allowFormat, 23 | jpegOptions: config.jpegOptions, 24 | webpOptions: config.webpOptions, 25 | axiosConfig: axios.axiosConfig, 26 | sentryDsn: server.sentriDsn 27 | } 28 | -------------------------------------------------------------------------------- /config/server.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | httpHost: '0.0.0.0', 3 | httpPort: 3000, 4 | logLevel: 'error', // One of 'fatal', 'error', 'warn', 'info', 'debug', 'trace' or 'silent' 5 | sentriDsn: '' 6 | } 7 | -------------------------------------------------------------------------------- /config/swagger.js: -------------------------------------------------------------------------------- 1 | const server = require('./server') 2 | 3 | exports.options = { 4 | routePrefix: '/swagger', 5 | exposeRoute: true, 6 | swagger: { 7 | info: { 8 | title: 'Imgresizer API', 9 | description: 'Proxy server for image resizing on Node.JS use (fastify, axios, sharp)', 10 | version: '0.2.0' 11 | }, 12 | externalDocs: { 13 | url: 'https://swagger.io', 14 | description: 'Find more info' 15 | }, 16 | host: `localhost:${process.env.PORT || server.httpPort}`, 17 | schemes: ['http'], 18 | consumes: ['application/json'], 19 | produces: ['application/json'] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /controllers/apiController.js: -------------------------------------------------------------------------------- 1 | const CONFIG = require('../config') 2 | const boom = require('@hapi/boom') 3 | const fastify = require('fastify')({ logger: { level: CONFIG.logLevel } }) 4 | const mainController = require('./mainController') 5 | 6 | exports.getFile = async (req, reply) => { 7 | try { 8 | const id = req.params.id 9 | const body = req.body 10 | const file = { 11 | id: id, 12 | body: body 13 | } 14 | fastify.log.info('getFile', file) 15 | const savefile = await (mainController.getDownloadFileV3(id, body.url, body.filename)) 16 | return savefile 17 | } catch (err) { 18 | throw boom.boomify(err) 19 | } 20 | } 21 | 22 | exports.sharpImage = async (req, reply) => { 23 | try { 24 | // const fileId = req.params.id 25 | const options = req.body 26 | // await (req) => {} 27 | return options 28 | } catch (err) { 29 | throw boom.boomify(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /controllers/mainController.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const sharp = require('sharp') 3 | const qs = require('querystring') 4 | const fs = require('fs') 5 | const path = require('path') 6 | const crypto = require('crypto') 7 | const boom = require('@hapi/boom') 8 | const CONFIG = require('../config') 9 | 10 | const fastify = require('fastify')({ logger: { level: CONFIG.logLevel } }) 11 | 12 | const getFormat = (format) => { 13 | return CONFIG.allowFormat.includes(format) ? format : CONFIG.defaultFormat 14 | } 15 | 16 | const getUrlPath = (url) => { 17 | const hashStart = url.indexOf('#') 18 | if (hashStart !== -1) { 19 | url = url.slice(0, hashStart) 20 | } 21 | 22 | return url.split('?')[0] || url 23 | } 24 | 25 | const parseReq = (req, acceptWebp) => { 26 | const hash = crypto.createHash('md5') 27 | const uriPath = getUrlPath(req.raw.url) 28 | let data = {} 29 | data.query = req.query 30 | // parsing parameters from request 31 | data.img = {} 32 | data.img.w = parseInt(data.query.w) || CONFIG.defaultWidth 33 | data.img.h = parseInt(data.query.h) || CONFIG.defaultHeight 34 | data.img.q = parseInt(data.query.q) || CONFIG.defaultQuality 35 | if (acceptWebp && !data.query.fm) { 36 | data.img.fm = 'webp' 37 | } else { 38 | data.img.fm = getFormat(data.query.fm) 39 | } 40 | // query formation 41 | delete data.query.w 42 | delete data.query.h 43 | delete data.query.q 44 | delete data.query.fm 45 | data.uri = Object.keys(data.query).length ? `${uriPath}?${qs.stringify(data.query)}` : uriPath 46 | data.hash = hash.update(data.uri).digest('hex') 47 | data.folder = data.hash.substring(0, 2) 48 | data.sourceFilename = data.hash.substring(2) 49 | createFolder(path.join(CONFIG.sourceFolder, data.folder)) 50 | createFolder(path.join(CONFIG.destinationFolder, data.folder)) 51 | return data 52 | } 53 | 54 | /* const getFileSize = (filePath) => { 55 | const stat = fs.statSync(filePath) 56 | const size = stat.size 57 | let i = Math.floor(Math.log(size) / Math.log(1024)) 58 | return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i] 59 | } */ 60 | 61 | const isAcceptWebp = (accept) => { 62 | const patternWebp = /image\/webp/ 63 | return !!accept.match(patternWebp) 64 | } 65 | 66 | const getSourceFilename = (reqImg) => { 67 | return path.join( 68 | CONFIG.sourceFolder, 69 | reqImg.folder, 70 | reqImg.sourceFilename 71 | ) 72 | } 73 | 74 | const getDestFileName = (reqImg) => { 75 | const filename = reqImg.sourceFilename 76 | const img = reqImg.img 77 | const imgW = img.w ? `_w${img.w}_` : `` 78 | const imgH = img.h ? `_h${img.h}_` : `` 79 | const imgQ = img.q ? `q${img.q}.` : `.` 80 | const ext = img.fm 81 | // ToDo add another formats 82 | return path.join( 83 | CONFIG.destinationFolder, 84 | reqImg.folder, 85 | filename + imgW + imgH + imgQ + ext) 86 | } 87 | 88 | const isAllowFileType = (contentType) => { 89 | const type = contentType.split('/') 90 | let allowType = false 91 | if (type[0] === 'image' && CONFIG.allowFormat.includes(type[1])) { 92 | allowType = true 93 | } 94 | return allowType 95 | } 96 | 97 | const processingImg = async (settings, rep) => { 98 | const imgOptions = { 99 | width: settings.img.w, 100 | height: settings.img.h, 101 | quality: settings.img.q, 102 | fit: CONFIG.defaultFit 103 | } 104 | let successful = false 105 | let options = {} 106 | let imgFormat = 'jpeg' 107 | switch (settings.img.fm) { 108 | case 'webp': 109 | options = { ...CONFIG.webpOptions, 110 | quality: settings.img.q 111 | } 112 | imgFormat = 'webp' 113 | break 114 | default: 115 | options = { ...CONFIG.jpegOptions, 116 | quality: settings.img.q 117 | } 118 | break 119 | } 120 | sharp.cache(false) 121 | sharp(settings.source) 122 | .resize(imgOptions) 123 | .toFormat(imgFormat, options) 124 | .toFile(settings.destination) 125 | .then((info) => { 126 | fastify.log.info(info) 127 | successful = true 128 | rep.sendFile(settings.destination) 129 | }) 130 | .catch((error) => { 131 | fastify.log.error(error) 132 | rep.send(error) 133 | }) 134 | return successful 135 | } 136 | 137 | const getDownloadFile = async (settings, rep) => { 138 | if (isPathExists(settings.source)) { 139 | fastify.log.info('source img exists') 140 | return processingImg(settings, rep) 141 | } else { 142 | const axiosGetFile = axios.create(CONFIG.axiosConfig) 143 | const writeStream = fs.createWriteStream(settings.source) 144 | writeStream.on('finish', () => { 145 | fastify.log.info('save file finish') 146 | return processingImg(settings, rep) 147 | }) 148 | fastify.log.info(`start download file -> ${settings.url}`) 149 | const respData = await axiosGetFile(settings.url) 150 | .then((response) => { 151 | if (isAllowFileType(response.headers['content-type']) && response.status === 200) { 152 | fastify.log.info(`download complete ${settings.url} -> ${response.status} ${response.headers['content-length']} ${response.headers['content-type']}`) 153 | response.data.pipe(writeStream) 154 | return response.data 155 | } else { 156 | boom.unsupportedMediaType('source file incorrect format') 157 | } 158 | }) 159 | .catch((error) => { 160 | rep.send(error) 161 | }) 162 | return respData 163 | } 164 | } 165 | 166 | const getDownloadFileV3 = async (id, url, filename) => { 167 | return { test: true } 168 | } 169 | 170 | const getSettings = (req) => { 171 | const acceptWebp = isAcceptWebp(req.headers.accept) 172 | const reqImg = parseReq(req, acceptWebp) 173 | const url = CONFIG.baseURL + reqImg.uri 174 | 175 | const sourceFilename = getSourceFilename(reqImg) 176 | const destFilename = getDestFileName(reqImg, acceptWebp) 177 | 178 | return { 179 | url: url, 180 | img: reqImg.img, 181 | source: sourceFilename, 182 | destination: destFilename, 183 | webp: acceptWebp, 184 | hash: reqImg.hash 185 | } 186 | } 187 | 188 | const isPathExists = (filepath) => { 189 | return fs.existsSync(filepath) 190 | } 191 | 192 | const createFolder = (folder) => { 193 | return isPathExists(folder) ? true : fs.mkdirSync(folder) 194 | } 195 | 196 | const getImage = async (req, rep) => { 197 | const settings = getSettings(req) 198 | fastify.log.info('settings request:', settings) 199 | 200 | if (isPathExists(settings.destination)) { 201 | fastify.log.info('img exists', settings.destination) 202 | rep.sendFile(settings.destination) 203 | } else { 204 | await getDownloadFile(settings, rep) 205 | } 206 | } 207 | 208 | const getData = async (settings) => { 209 | try { 210 | const t = await axios({ 211 | method: 'POST', 212 | url: `http://${CONFIG.httpHost}:${CONFIG.httpPort}/api/file/${settings.hash}`, 213 | data: { 214 | url: settings.url, 215 | filename: settings.source 216 | }, 217 | headers: { 218 | 'Accept': 'application/json', 219 | 'Content-Type': 'application/json;charset=utf-8' 220 | } 221 | }) 222 | .catch((e) => { 223 | fastify.log.error('post ', e) 224 | boom.boomify(e) 225 | }) 226 | return t.data 227 | } catch (e) { 228 | fastify.log.error('getData', e) 229 | boom.boomify(e) 230 | } 231 | } 232 | 233 | const getImageV3 = async (req, rep) => { 234 | const settings = getSettings(req) 235 | // fastify.log.info('settings request:', settings) 236 | const test = await getData(settings) 237 | 238 | return test 239 | } 240 | 241 | const getHeaders = async (req, rep) => { 242 | return req.headers 243 | } 244 | 245 | const getRoot = async (req, rep) => { 246 | return { 247 | server: 'ok' 248 | } 249 | } 250 | 251 | module.exports = { 252 | createFolder, 253 | getImage, 254 | getImageV3, 255 | getDownloadFileV3, 256 | getHeaders, 257 | getRoot, 258 | getFormat, 259 | isPathExists, 260 | getSettings, 261 | isAllowFileType, 262 | isAcceptWebp 263 | } 264 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: 5 | context: . 6 | dockerfile: ./Dockerfile 7 | image: node:alpine 8 | environment: 9 | NODE_ENV: production 10 | PLATFORM: linuxmusl-x64 11 | ports: 12 | - "3000:3000" 13 | # volumes: 14 | # - .:/app 15 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-midnight -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker tips 2 | 3 | ## Create docker image 4 | 5 | ```bash 6 | $ docker build -t node-imgoptimize . 7 | $ docker run -it --rm -p 3000:3000 node-imgoptimize 8 | ``` 9 | 10 | use docker-compose, simple commands 11 | 12 | ```bash 13 | $ docker-compose build 14 | $ docker-compose up 15 | ``` 16 | 17 | ## FAQ 18 | 19 | ### How can I delete Docker's images? 20 | 21 | delete all images 22 | 23 | ```bash 24 | $ docker rmi $(docker images -q) 25 | ``` 26 | 27 | delete all containers 28 | 29 | ```bash 30 | $ docker rm $(docker ps -a -q) 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/install_heroku.md: -------------------------------------------------------------------------------- 1 | # Install and deploy on Heroku 2 | 3 | ## Prepare install 4 | 5 | Of course you should already have GIT, Node.js and NPM installed... but if not :smiley: 6 | [Install GIT](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 7 | [Install Node.js](https://github.com/nodesource/distributions/blob/master/README.md) 8 | 9 | ```text 10 | $ node --version 11 | $ npm --version 12 | $ git --version 13 | ``` 14 | 15 | ## Install Heroku CLI 16 | 17 | https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up 18 | 19 | ## Login 20 | 21 | ```text 22 | $ heroku login 23 | ``` 24 | 25 | ## Deploying to Heroku 26 | 27 | ```text 28 | $ heroku create node-imgoptimize 29 | $ git push heroku master 30 | $ heroku open 31 | ``` 32 | 33 | or 34 | 35 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/AntonLukichev/node-imgoptimize) 36 | -------------------------------------------------------------------------------- /docs/install_ubuntu.md: -------------------------------------------------------------------------------- 1 | # Install on Ubuntu 16.04 2 | 3 | ## Install 4 | 5 | ### Install Node.js v10.x 6 | 7 | [Installing Node.js](https://github.com/nodesource/distributions/blob/master/README.md) 8 | 9 | ```text 10 | $ curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - 11 | $ sudo apt-get install -y nodejs build-essential 12 | $ npm install -g yarn 13 | ``` 14 | 15 | ### Clone project 16 | 17 | ```text 18 | $ git clone https://github.com/AntonLukichev/node-imgoptimize.git 19 | $ yarn install 20 | 21 | $ cd .. 22 | $ chmod +x ./app.js 23 | ``` 24 | 25 | ### Install PM2 26 | 27 | ```text 28 | $ npm i -g pm2 29 | $ pm2 start app.js 30 | $ pm2 startup 31 | ``` 32 | 33 | ## Log rotation 34 | 35 | [Log rotation setup](log_rotation.md) 36 | -------------------------------------------------------------------------------- /docs/log_rotation.md: -------------------------------------------------------------------------------- 1 | ## Show logs 2 | 3 | ```text 4 | $ pm2 logs 5 | ``` 6 | 7 | Logs file 8 | 9 | ```text 10 | $ ls -l ~/.pm2/logs 11 | ``` 12 | 13 | ## Install module 14 | 15 | ```text 16 | $ pm2 install pm2-logrotate 17 | ``` 18 | 19 | ## Settings 20 | 21 | https://github.com/keymetrics/pm2-logrotate#configure 22 | 23 | ```text 24 | $ pm2 set pm2-logrotate:compress true 25 | $ pm2 set pm2-logrotate:retain 7 26 | $ pm2 set pm2-logrotate:max_size 5M 27 | ``` 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-imgoptimize", 3 | "version": "0.4.5", 4 | "description": "Proxy server for image optimization on Node.JS use (fastify, axios, sharp)", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "dev": "set NODE_ENV=development&& nodemon app.js", 9 | "lint": "eslint --ignore-path .gitignore .", 10 | "lint-fix": "eslint --ignore-path .gitignore . --fix", 11 | "test": "jest --coverage" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/AntonLukichev/node-imgoptimize.git" 16 | }, 17 | "keywords": [ 18 | "node-js", 19 | "image-processing", 20 | "webp", 21 | "jpeg optimize", 22 | "fastify", 23 | "axios", 24 | "sharp", 25 | "libvips" 26 | ], 27 | "author": { 28 | "name": "Anton Lukichev", 29 | "email": "anton@lukichev.pro" 30 | }, 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/AntonLukichev/node-imgoptimize/issues" 34 | }, 35 | "homepage": "https://github.com/AntonLukichev/node-imgoptimize#readme", 36 | "engines": { 37 | "node": ">=8.x", 38 | "npm": ">=6.x" 39 | }, 40 | "dependencies": { 41 | "@hapi/boom": "^9.1.0", 42 | "@sentry/node": "^5.14.2", 43 | "axios": "^0.19.2", 44 | "fastify": "^2.12.1", 45 | "fastify-favicon": "^2.0.0", 46 | "fastify-response-time": "^1.1.0", 47 | "fastify-static": "^2.6.0", 48 | "fastify-swagger": "^2.5.0", 49 | "fastify-url-data": "^2.4.0", 50 | "sharp": "^0.25.1", 51 | "typescript": "^3.8.3" 52 | }, 53 | "devDependencies": { 54 | "eslint": "^6.8.0", 55 | "eslint-config-standard": "^14.1.0", 56 | "eslint-plugin-import": "^2.20.1", 57 | "eslint-plugin-jest": "^23.8.2", 58 | "eslint-plugin-node": "^11.0.0", 59 | "eslint-plugin-promise": "^4.2.1", 60 | "eslint-plugin-standard": "^4.0.1", 61 | "jest": "^25.1.0", 62 | "nodemon": "^2.0.2", 63 | "prettier": "^2.0.0" 64 | }, 65 | "jest": { 66 | "verbose": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const apiController = require('../controllers/apiController') 2 | const mainController = require('../controllers/mainController') 3 | const schema = require('../schemas') 4 | const CONFIG = require('../config') 5 | 6 | const routes = [ 7 | { 8 | method: 'POST', 9 | url: '/api/file/:id', 10 | schema: schema.getFile, 11 | handler: apiController.getFile 12 | }, 13 | { 14 | method: 'POST', 15 | url: '/api/image/:id', 16 | handler: apiController.sharpImage 17 | }, 18 | { 19 | method: 'GET', 20 | url: '/api/headers/', 21 | handler: mainController.getHeaders 22 | }, 23 | { 24 | method: 'GET', 25 | url: '/', 26 | handler: mainController.getRoot 27 | }, 28 | { 29 | method: 'GET', 30 | url: '/api/test/*', 31 | handler: mainController.getImageV3 32 | } 33 | ] 34 | 35 | CONFIG.pathURI.forEach((pathURI, index) => { 36 | routes.push({ 37 | method: 'GET', 38 | url: pathURI + '*', 39 | handler: mainController.getImage 40 | }) 41 | }) 42 | 43 | module.exports = routes 44 | -------------------------------------------------------------------------------- /schemas/index.js: -------------------------------------------------------------------------------- 1 | exports.getFile = { 2 | params: { 3 | type: 'object', 4 | properties: { 5 | id: { 6 | type: 'string', 7 | description: 'file id' 8 | } 9 | } 10 | }, 11 | body: { 12 | type: 'object', 13 | properties: { 14 | url: { type: 'string' }, 15 | filename: { type: 'string' } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { COPYFILE_EXCL } = fs.constants 3 | 4 | const cpSync = (src, dest, flag = COPYFILE_EXCL) => { 5 | try { 6 | fs.copyFileSync(src, dest, flag) 7 | return true 8 | } catch (e) { 9 | return false 10 | } 11 | } 12 | 13 | module.exports = { 14 | cpSync 15 | } 16 | --------------------------------------------------------------------------------