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

Commit 7ffa4d1

Browse files
committed
Implemented basic image processing/caching factories
With this we get a local action witch is the current behavior. Then we have file caching which is disabled by default. This creates and md5 of the URL and for that he saves the processed image to file.
1 parent 51a0bef commit 7ffa4d1

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)