Skip to content
This repository was archived by the owner on May 28, 2023. It is now read-only.

Commit 589bb87

Browse files
authored
Merge pull request #315 from gastrohero/feature/image-caching-and-image-prossesing
Implemented basic image processing/caching factories
2 parents 51a0bef + 7ffa4d1 commit 589bb87

File tree

11 files changed

+374
-108
lines changed

11 files changed

+374
-108
lines changed

config/default.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
"deprecatedPriceFieldsSupport": true,
7373
"calculateServerSide": true,
7474
"sourcePriceIncludesTax": false,
75-
"finalPriceIncludesTax": true
75+
"finalPriceIncludesTax": true
7676
},
7777
"i18n": {
7878
"fullCountryName": "Germany",
@@ -223,7 +223,17 @@
223223
"process": 4
224224
},
225225
"simd": true,
226-
"keepDownloads": true
226+
"keepDownloads": true,
227+
"caching": {
228+
"active": false,
229+
"type": "file",
230+
"file": {
231+
"path": "/tmp/vue-storefront-api"
232+
}
233+
},
234+
"action": {
235+
"type": "local"
236+
}
227237
},
228238
"entities": {
229239
"category": {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"elasticsearch": "^15.2.0",
6969
"email-check": "^1.1.0",
7070
"express": "^4.16.3",
71+
"fs-extra": "^8.1.0",
7172
"graphql": "^0.10.1",
7273
"graphql-tools": "^1.2.1",
7374
"humps": "^1.1.0",

src/api/img.js

Lines changed: 26 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,48 @@
11
// @ts-check
2-
import { downloadImage, fit, identify, resize } from '../lib/image';
3-
import mime from 'mime-types';
4-
import URL from 'url';
5-
6-
const SUPPORTED_ACTIONS = ['fit', 'resize', 'identify'];
7-
const SUPPORTED_MIMETYPES = ['image/gif', 'image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
8-
const ONE_YEAR = 31557600000;
2+
import CacheFactory from "../image/cache/factory";
3+
import ActionFactory from "../image/action/factory";
94

105
const asyncMiddleware = fn => (req, res, next) => {
116
Promise.resolve(fn(req, res, next)).catch(next);
127
};
138

149
export default ({ config, db }) =>
15-
asyncMiddleware(async (req, res, body) => {
10+
asyncMiddleware(async (req, res, next) => {
1611
if (!(req.method == 'GET')) {
1712
res.set('Allow', 'GET');
1813
return res.status(405).send('Method Not Allowed');
1914
}
15+
const cacheFactory = new CacheFactory(config, req)
2016

2117
req.socket.setMaxListeners(config.imageable.maxListeners || 50);
22-
23-
let width
24-
let height
25-
let action
26-
let imgUrl
27-
28-
if (req.query.url) { // url provided as the query param
29-
imgUrl = decodeURIComponent(req.query.url)
30-
width = parseInt(req.query.width)
31-
height = parseInt(req.query.height)
32-
action = req.query.action
33-
} else {
34-
let urlParts = req.url.split('/');
35-
width = parseInt(urlParts[1]);
36-
height = parseInt(urlParts[2]);
37-
action = urlParts[3];
38-
imgUrl = `${config[config.platform].imgUrl}/${urlParts.slice(4).join('/')}`; // full original image url
39-
40-
if (urlParts.length < 4) {
41-
return res.status(400).send({
42-
code: 400,
43-
result: 'Please provide following parameters: /img/<width>/<height>/<action:fit,resize,identify>/<relative_url>'
44-
});
45-
}
46-
}
4718

19+
let imageBuffer
4820

49-
if (isNaN(width) || isNaN(height) || !SUPPORTED_ACTIONS.includes(action)) {
50-
return res.status(400).send({
51-
code: 400,
52-
result: 'Please provide following parameters: /img/<width>/<height>/<action:fit,resize,identify>/<relative_url> OR ?url=&width=&height=&action='
53-
});
54-
}
55-
56-
if (width > config.imageable.imageSizeLimit || width < 0 || height > config.imageable.imageSizeLimit || height < 0) {
57-
return res.status(400).send({
58-
code: 400,
59-
result: `Width and height must have a value between 0 and ${config.imageable.imageSizeLimit}`
60-
});
61-
}
62-
63-
if (!isImageSourceHostAllowed(imgUrl, config.imageable.whitelist)) {
64-
return res.status(400).send({
65-
code: 400,
66-
result: `Host is not allowed`
67-
});
68-
}
21+
const actionFactory = new ActionFactory(req , res, next, config)
22+
const imageAction = actionFactory.getAdapter(config.imageable.action.type)
23+
imageAction.getOption()
24+
imageAction.validateOptions()
25+
imageAction.isImageSourceHostAllowed()
26+
imageAction.validateMIMEType()
6927

70-
const mimeType = mime.lookup(imgUrl);
28+
const cache = cacheFactory.getAdapter(config.imageable.caching.type)
7129

72-
if (mimeType === false || !SUPPORTED_MIMETYPES.includes(mimeType)) {
73-
return res.status(400).send({
74-
code: 400,
75-
result: 'Unsupported file type'
76-
});
77-
}
30+
if (config.imageable.caching.active && await cache.check()) {
31+
await cache.getImageFromCache()
32+
imageBuffer = cache.image
33+
} else {
34+
await imageAction.prossesImage()
7835

79-
console.log(`[URL]: ${imgUrl} - [ACTION]: ${action} - [WIDTH]: ${width} - [HEIGHT]: ${height}`);
36+
if (config.imageable.caching.active) {
37+
cache.image = imageAction.imageBuffer
38+
await cache.save()
39+
}
8040

81-
let buffer;
82-
try {
83-
buffer = await downloadImage(imgUrl);
84-
} catch (err) {
85-
return res.status(400).send({
86-
code: 400,
87-
result: `Unable to download the requested image ${imgUrl}`
88-
});
41+
imageBuffer = imageAction.imageBuffer
8942
}
9043

91-
switch (action) {
92-
case 'resize':
93-
return res
94-
.type(mimeType)
95-
.set({ 'Cache-Control': `max-age=${ONE_YEAR}` })
96-
.send(await resize(buffer, width, height));
97-
case 'fit':
98-
return res
99-
.type(mimeType)
100-
.set({ 'Cache-Control': `max-age=${ONE_YEAR}` })
101-
.send(await fit(buffer, width, height));
102-
case 'identify':
103-
return res.set({ 'Cache-Control': `max-age=${ONE_YEAR}` }).send(await identify(buffer));
104-
default:
105-
throw new Error('Unknown action');
106-
}
44+
return res
45+
.type(imageAction.mimeType)
46+
.set({ 'Cache-Control': `max-age=${imageAction.maxAgeForResponse}` })
47+
.send(imageBuffer);
10748
});
108-
109-
function _isUrlWhitelisted(url, whitelistType, defaultValue, whitelist) {
110-
if (arguments.length != 4) throw new Error('params are not optional!');
111-
112-
if (whitelist && whitelist.hasOwnProperty(whitelistType)) {
113-
const requestedHost = URL.parse(url).host;
114-
const matches = whitelist[whitelistType].map(allowedHost => {
115-
allowedHost = allowedHost instanceof RegExp ? allowedHost : new RegExp(allowedHost);
116-
return !!requestedHost.match(allowedHost);
117-
});
118-
119-
return matches.indexOf(true) > -1;
120-
} else {
121-
return defaultValue;
122-
}
123-
}
124-
125-
function isImageSourceHostAllowed(url, whitelist) {
126-
return _isUrlWhitelisted(url, 'allowedHosts', true, whitelist);
127-
}

src/image/action/abstract/index.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { NextFunction, Request, Response } from 'express'
2+
import URL from 'url'
3+
4+
export default abstract class ImageAction {
5+
6+
readonly SUPPORTED_ACTIONS = ['fit', 'resize', 'identify']
7+
readonly SUPPORTED_MIMETYPES
8+
9+
req: Request
10+
res: Response
11+
next: NextFunction
12+
options
13+
mimeType: string
14+
15+
constructor(req: Request, res: Response, next: NextFunction, options) {
16+
this.req = req
17+
this.res = res
18+
this.next = next
19+
this.options = options
20+
}
21+
22+
abstract getOption(): void
23+
24+
abstract validateOptions(): void
25+
26+
abstract getImageURL(): string
27+
28+
abstract get whitelistDomain(): string[]
29+
30+
abstract validateMIMEType(): void
31+
32+
abstract prossesImage(): void
33+
34+
isImageSourceHostAllowed() {
35+
if (!this._isUrlWhitelisted(this.getImageURL(), 'allowedHosts', true, this.whitelistDomain)) {
36+
return this.res.status(400).send({
37+
code: 400,
38+
result: `Host is not allowed`
39+
})
40+
}
41+
}
42+
43+
44+
_isUrlWhitelisted(url, whitelistType, defaultValue, whitelist) {
45+
if (arguments.length != 4) throw new Error('params are not optional!')
46+
47+
if (whitelist && whitelist.hasOwnProperty(whitelistType)) {
48+
const requestedHost = URL.parse(url).host
49+
50+
const matches = whitelist[whitelistType].map(allowedHost => {
51+
allowedHost = allowedHost instanceof RegExp ? allowedHost : new RegExp(allowedHost)
52+
return !!requestedHost.match(allowedHost)
53+
})
54+
return matches.indexOf(true) > -1
55+
} else {
56+
return defaultValue
57+
}
58+
}
59+
}

src/image/action/factory.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict';
2+
3+
import { NextFunction, Request, Response } from 'express'
4+
import { IConfig } from "config";
5+
6+
export default class ActionFactory {
7+
8+
request: Request
9+
next: NextFunction
10+
response: Response
11+
config: IConfig
12+
13+
constructor(req: Request, res, next, app_config) {
14+
this.request = req
15+
this.response = res
16+
this.next = next
17+
this.config = app_config;
18+
}
19+
20+
getAdapter(type: String): any {
21+
let adapter_class = require(`./${type}`).default
22+
if (!adapter_class) {
23+
throw new Error(`Invalid adapter ${type}`);
24+
} else {
25+
let adapter_instance = new adapter_class(this.request, this.response, this.next, this.config);
26+
if((typeof adapter_instance.isValidFor == 'function') && !adapter_instance.isValidFor(type))
27+
throw new Error(`Not valid adapter class or adapter is not valid for ${type}`);
28+
return adapter_instance;
29+
}
30+
}
31+
}
32+
33+
export {
34+
ActionFactory
35+
};

src/image/action/local/index.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import ImageAction from '../abstract'
2+
import mime from 'mime-types'
3+
import { downloadImage, fit, identify, resize } from '../../../lib/image'
4+
5+
export default class LocalImageAction extends ImageAction {
6+
7+
imageOptions
8+
SUPPORTED_MIMETYPES = ['image/gif', 'image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
9+
imageBuffer: Buffer
10+
11+
get whitelistDomain(): string[] {
12+
return this.options.imageable.whitelist
13+
}
14+
15+
get maxAgeForResponse() {
16+
return 31557600000
17+
}
18+
19+
getImageURL(): string {
20+
return this.imageOptions.imgUrl
21+
}
22+
23+
getOption() {
24+
let imgUrl: string
25+
let width: number
26+
let height: number
27+
let action: string
28+
if (this.req.query.url) { // url provided as the query param
29+
imgUrl = decodeURIComponent(this.req.query.url)
30+
width = parseInt(this.req.query.width)
31+
height = parseInt(this.req.query.height)
32+
action = this.req.query.action
33+
} else {
34+
let urlParts = this.req.url.split('/')
35+
width = parseInt(urlParts[1])
36+
height = parseInt(urlParts[2])
37+
action = urlParts[3]
38+
imgUrl = `${this.options[this.options.platform].imgUrl}/${urlParts.slice(4).join('/')}` // full original image url
39+
if (urlParts.length < 5) {
40+
this.res.status(400).send({
41+
code: 400,
42+
result: 'Please provide following parameters: /img/<type>/<width>/<height>/<action:fit,resize,identify>/<relative_url>'
43+
})
44+
this.next()
45+
}
46+
}
47+
48+
this.imageOptions = {
49+
imgUrl,
50+
width,
51+
height,
52+
action
53+
}
54+
}
55+
56+
validateOptions() {
57+
const { width, height, action } = this.imageOptions
58+
if (isNaN(width) || isNaN(height) || !this.SUPPORTED_ACTIONS.includes(action)) {
59+
return this.res.status(400).send({
60+
code: 400,
61+
result: 'Please provide following parameters: /img/<type>/<width>/<height>/<action:fit,resize,identify>/<relative_url> OR ?url=&width=&height=&action='
62+
})
63+
}
64+
65+
if (width > this.options.imageable.imageSizeLimit || width < 0 || height > this.options.imageable.imageSizeLimit || height < 0) {
66+
return this.res.status(400).send({
67+
code: 400,
68+
result: `Width and height must have a value between 0 and ${this.options.imageable.imageSizeLimit}`
69+
})
70+
}
71+
}
72+
73+
validateMIMEType() {
74+
const mimeType = mime.lookup(this.imageOptions.imgUrl)
75+
76+
if (mimeType === false || !this.SUPPORTED_MIMETYPES.includes(mimeType)) {
77+
return this.res.status(400).send({
78+
code: 400,
79+
result: 'Unsupported file type'
80+
})
81+
}
82+
83+
this.mimeType = mimeType
84+
}
85+
86+
async prossesImage() {
87+
const { imgUrl } = this.imageOptions
88+
89+
try {
90+
this.imageBuffer = await downloadImage(imgUrl)
91+
} catch (err) {
92+
return this.res.status(400).send({
93+
code: 400,
94+
result: `Unable to download the requested image ${imgUrl}`
95+
})
96+
}
97+
const { action, width, height } = this.imageOptions
98+
switch (action) {
99+
case 'resize':
100+
this.imageBuffer = await resize(this.imageBuffer, width, height)
101+
break
102+
case 'fit':
103+
this.imageBuffer = await fit(this.imageBuffer, width, height)
104+
break
105+
case 'identify':
106+
this.imageBuffer = await identify(this.imageBuffer)
107+
break
108+
default:
109+
throw new Error('Unknown action')
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)