├── .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 | [](https://travis-ci.org/AntonLukichev/node-imgoptimize)
4 | [](https://codeclimate.com/github/AntonLukichev/node-imgoptimize/maintainability)
5 | [](https://codeclimate.com/github/AntonLukichev/node-imgoptimize/test_coverage)
6 |
7 | [](http://standardjs.com/)
8 | 
9 | [](LICENSE)
10 |
11 | [](https://github.com/AntonLukichev/node-imgoptimize/releases)
12 | [](https://www.npmjs.com/package/node-imgoptimize)
13 | [](https://snyk.io/test/github/AntonLukichev/node-imgoptimize?targetFile=package.json)
14 | [](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 | [](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 | [](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 |
--------------------------------------------------------------------------------