@abtnode/blocklet-services 1.16.14-beta-9743e8e2 → 1.16.14-beta-c775fe49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/index.js CHANGED
@@ -30,6 +30,7 @@ const BlockletEventsNotifier = require('./services/notification/blocklet-events-
30
30
  const { init: initAuth } = require('./services/auth');
31
31
  const { init: initDashboard } = require('./services/dashboard');
32
32
  const StaticService = require('./services/static');
33
+ const initImageService = require('./services/image');
33
34
  const StudioService = require('./services/studio');
34
35
  const AnalyticService = require('./services/analytics');
35
36
  const createEnvRoutes = require('./routes/env');
@@ -89,6 +90,7 @@ module.exports = function createServer(node, serverOptions = {}) {
89
90
  const { middlewares: authMiddlewares, routes: authRoutes, ensureWsUser } = initAuth({ node, options });
90
91
  const notificationService = initNotification({ node });
91
92
  const relayService = initRelay({ node });
93
+ const imageService = initImageService({ node });
92
94
  const dashboardService = initDashboard({ node, ensureWsUser });
93
95
 
94
96
  // Proxy engine
@@ -303,9 +305,12 @@ module.exports = function createServer(node, serverOptions = {}) {
303
305
  try {
304
306
  const { target } = await ensureProxyUrl(req);
305
307
  if (target) {
306
- proxy.safeWeb(req, res, {
307
- target,
308
- });
308
+ if (imageService.isImageAccepted(req) && imageService.isImageRequest(req)) {
309
+ req.target = target; // for internal use
310
+ imageService.processImage(req, res);
311
+ } else {
312
+ proxy.safeWeb(req, res, { target });
313
+ }
309
314
  } else {
310
315
  throw new Error('empty proxy target');
311
316
  }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="300" height="200" viewBox="0 0 300 200"><rect width="100%" height="100%" fill="#DDDDDD"/><path fill="#999999" d="M112.5 94.58q0 5.4-1.79 10.01-1.78 4.61-5.05 7.97-3.26 3.37-7.85 5.26-4.59 1.88-10.2 1.88-5.58 0-10.17-1.88-4.59-1.89-7.87-5.26-3.28-3.36-5.08-7.97-1.8-4.61-1.8-10.01 0-5.41 1.8-10.02 1.8-4.6 5.08-7.97 3.28-3.36 7.87-5.25 4.59-1.89 10.17-1.89 3.74 0 7.04.87 3.29.87 6.05 2.45 2.75 1.58 4.94 3.84 2.2 2.26 3.73 5.05 1.53 2.79 2.33 6.05t.8 6.87Zm-9.35 0q0-4.05-1.09-7.26t-3.1-5.46q-2-2.24-4.88-3.43-2.87-1.19-6.47-1.19-3.61 0-6.48 1.19-2.87 1.19-4.9 3.43-2.02 2.25-3.11 5.46-1.08 3.21-1.08 7.26 0 4.04 1.08 7.26 1.09 3.21 3.11 5.44 2.03 2.22 4.9 3.41 2.87 1.19 6.48 1.19 3.6 0 6.47-1.19 2.88-1.19 4.88-3.41 2.01-2.23 3.1-5.44 1.09-3.22 1.09-7.26Zm31.51-10.85q3.88 0 7.06 1.26 3.18 1.26 5.44 3.57 2.26 2.31 3.48 5.64 1.23 3.34 1.23 7.45 0 4.15-1.23 7.48-1.22 3.33-3.48 5.68-2.26 2.34-5.44 3.6-3.18 1.26-7.06 1.26-3.91 0-7.1-1.26-3.2-1.26-5.46-3.6-2.26-2.35-3.5-5.68-1.24-3.33-1.24-7.48 0-4.11 1.24-7.45 1.24-3.33 3.5-5.64 2.26-2.31 5.46-3.57 3.19-1.26 7.1-1.26Zm0 29.48q4.36 0 6.45-2.92 2.09-2.93 2.09-8.57 0-5.65-2.09-8.6-2.09-2.96-6.45-2.96-4.42 0-6.54 2.97-2.13 2.98-2.13 8.59 0 5.61 2.13 8.55 2.12 2.94 6.54 2.94Zm32.03-18.73v15.64q1.43 1.73 3.11 2.44 1.69.72 3.66.72 1.9 0 3.43-.72 1.53-.71 2.6-2.17t1.65-3.69q.58-2.23.58-5.25 0-3.06-.49-5.19-.5-2.12-1.41-3.45-.92-1.33-2.23-1.94-1.31-.61-2.98-.61-2.61 0-4.45 1.1-1.84 1.11-3.47 3.12Zm-1.12-8.67.68 3.23q2.14-2.42 4.86-3.91 2.72-1.5 6.39-1.5 2.86 0 5.22 1.19 2.37 1.19 4.08 3.45 1.72 2.26 2.66 5.58.93 3.31.93 7.6 0 3.91-1.05 7.24-1.06 3.33-3.01 5.78-1.96 2.45-4.73 3.82-2.77 1.38-6.2 1.38-2.93 0-5-.9-2.08-.9-3.71-2.5v14.28h-8.4V84.28h5.14q1.63 0 2.14 1.53Zm54.54 2.24-1.91 3.03q-.34.54-.71.76-.38.22-.95.22-.62 0-1.31-.34-.7-.34-1.62-.76-.92-.43-2.09-.77t-2.77-.34q-2.48 0-3.89 1.06-1.41 1.05-1.41 2.75 0 1.12.73 1.89.73.76 1.93 1.34 1.21.58 2.74 1.04 1.53.46 3.11 1 1.58.54 3.11 1.24 1.53.7 2.74 1.77 1.21 1.07 1.94 2.57.73 1.49.73 3.6 0 2.52-.9 4.64-.9 2.13-2.67 3.67-1.77 1.55-4.37 2.42-2.6.86-6 .86-1.8 0-3.52-.32t-3.3-.9q-1.58-.58-2.92-1.36-1.34-.78-2.36-1.7l1.93-3.2q.38-.57.89-.88.51-.31 1.29-.31t1.48.45q.7.44 1.61.95.92.51 2.16.95 1.24.44 3.15.44 1.49 0 2.56-.36 1.08-.35 1.77-.93.7-.58 1.02-1.34.33-.77.33-1.58 0-1.23-.73-2.01-.74-.78-1.94-1.36-1.21-.58-2.76-1.04-1.54-.46-3.16-1-1.61-.54-3.16-1.27-1.55-.74-2.76-1.86-1.2-1.12-1.93-2.75t-.73-3.95q0-2.14.85-4.08.85-1.93 2.5-3.38 1.64-1.44 4.11-2.31 2.46-.87 5.69-.87 3.61 0 6.57 1.19 2.95 1.19 4.93 3.13ZM228.3 70h7.89v19.44q0 3.1-.31 6.07-.3 2.98-.78 6.31h-5.71q-.48-3.33-.78-6.31-.31-2.97-.31-6.07V70Zm-1.33 44.54q0-1.06.4-2.01.39-.95 1.08-1.63.7-.68 1.65-1.09.95-.41 2.04-.41 1.06 0 2.01.41.95.41 1.65 1.09.69.68 1.1 1.63.41.95.41 2.01 0 1.08-.41 2.02-.41.93-1.1 1.61-.7.68-1.65 1.08-.95.39-2.01.39-1.09 0-2.04-.39-.95-.4-1.65-1.08-.69-.68-1.08-1.61-.4-.94-.4-2.02Z"/></svg>
@@ -0,0 +1,235 @@
1
+ // A simple image service based on sharp
2
+ // @link https://sharp.pixelplumbing.com/
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const sharp = require('sharp');
6
+ const { Joi } = require('@arcblock/validator');
7
+ const stringify = require('json-stable-stringify');
8
+ const md5 = require('@abtnode/util/lib/md5');
9
+ const formatError = require('@abtnode/util/lib/format-error');
10
+
11
+ const logger = require('@abtnode/logger')(require('../../../../package.json').name);
12
+
13
+ const errorImage = path.resolve(__dirname, './error.svg');
14
+
15
+ const FORMATS = ['png', 'jpeg', 'webp', 'avif', 'heif'];
16
+ const OPERATIONS = ['none', 'resize', 'crop'];
17
+ const QUALITIES = {
18
+ png: 100,
19
+ jpeg: 80,
20
+ webp: 80,
21
+ avif: 50,
22
+ heif: 50,
23
+ };
24
+
25
+ const EXTENSIONS = {
26
+ png: 'png',
27
+ jpeg: 'jpeg',
28
+ jpg: 'jpeg',
29
+ webp: 'webp',
30
+ avif: 'avif',
31
+ heif: 'heif',
32
+ };
33
+
34
+ const schema = Joi.object({
35
+ imageFilter: Joi.string()
36
+ .valid(...OPERATIONS)
37
+ .required(),
38
+
39
+ w: Joi.number().integer().min(1).max(2048).when('imageFilter', {
40
+ is: 'crop',
41
+ then: Joi.required(),
42
+ otherwise: Joi.optional(),
43
+ }),
44
+ h: Joi.number().integer().min(1).max(2048).when('imageFilter', {
45
+ is: 'crop',
46
+ then: Joi.required(),
47
+ otherwise: Joi.optional(),
48
+ }),
49
+
50
+ q: Joi.number()
51
+ .integer()
52
+ .min(10)
53
+ .max(100)
54
+ .default((input) => {
55
+ if (input.f && QUALITIES[input.f]) {
56
+ return QUALITIES[input.f];
57
+ }
58
+ return 90;
59
+ }),
60
+
61
+ p: Joi.number().valid(0, 1).optional().default(1), // progressive
62
+ g: Joi.number().valid(0, 1).optional().default(0), // greyscale
63
+ r: Joi.number().integer().valid(90, 180, 270).optional(), // rotate
64
+ s: Joi.number().integer().min(0).max(2000).optional(), // sharpen
65
+ b: Joi.number().integer().min(1).max(2000).optional(), // blur
66
+ a: Joi.number().valid(0, 1).optional().default(1), // alpha channel, transparency
67
+ n: Joi.number().valid(0, 1).optional().default(0), // negative
68
+
69
+ // image format
70
+ f: Joi.string()
71
+ .valid(...FORMATS)
72
+ .optional(),
73
+
74
+ // resize positions
75
+ m: Joi.string().valid('cover', 'contain', 'fill', 'inside', 'outside').optional().default('inside'),
76
+
77
+ // crop positions
78
+ t: Joi.number().integer().min(0).max(2048).optional().default(0),
79
+ l: Joi.number().integer().min(0).max(2048).optional().default(0),
80
+
81
+ e: Joi.number().valid(0, 1).optional().default(0), // return error
82
+ })
83
+ .or('w', 'h')
84
+ .rename('i', 'p')
85
+ .options({ stripUnknown: true, allowUnknown: true, noDefaults: false });
86
+
87
+ const isImageAccepted = (req) => {
88
+ return FORMATS.some((x) => req.accepts(`image/${x}`));
89
+ };
90
+
91
+ const isImageRequest = (req) => {
92
+ if (req.method !== 'GET') {
93
+ return false;
94
+ }
95
+ if (!req.query.imageFilter) {
96
+ return false;
97
+ }
98
+ const { error, value } = schema.validate(req.query);
99
+ if (error) {
100
+ logger.warn('image service filter params invalid:', formatError(error));
101
+ return false;
102
+ }
103
+
104
+ req.imageFilter = value;
105
+ return true;
106
+ };
107
+
108
+ const tasks = {};
109
+ const processAndRespond = (req, res, cacheDir, getSrc) => {
110
+ if (fs.existsSync(cacheDir) === false) {
111
+ fs.mkdirSync(cacheDir, { recursive: true });
112
+ }
113
+
114
+ const params = req.imageFilter;
115
+ const extension = path.extname(req.path).slice(1);
116
+ if (!extension && !params.f) {
117
+ res.status(400).send('Image filter failed: either extension or format must be specified');
118
+ return;
119
+ }
120
+
121
+ const cacheKey = md5(stringify({ target: req.target, path: req.path, params }));
122
+ const destPath = path.join(cacheDir, `${cacheKey}.${params.f || extension}`);
123
+ if (fs.existsSync(destPath)) {
124
+ res.header('Content-Type', `image/${params.f || extension}`);
125
+ res.sendFile(destPath, { maxAge: '356d', immutable: true });
126
+ return;
127
+ }
128
+
129
+ // do the convert
130
+ tasks[cacheKey] ??= getSrc(req)
131
+ .then(([src, ext]) => processImage(src, ext, destPath, params))
132
+ .finally(() => {
133
+ setTimeout(() => {
134
+ delete tasks[cacheKey];
135
+ }, 1000);
136
+ });
137
+
138
+ tasks[cacheKey]
139
+ .then(() => {
140
+ logger.info('image filter succeed', { params, url: req.url, destPath });
141
+ res.header('Content-Type', `image/${params.f || extension}`);
142
+ res.sendFile(destPath, { maxAge: '356d', immutable: true });
143
+ })
144
+ .catch((err) => {
145
+ logger.error('image filter failed', { error: err, params, url: req.url });
146
+ if (params.e) {
147
+ res.status(500).send(`Image service error: ${err.message}`);
148
+ } else {
149
+ res.status(500);
150
+ res.sendFile(errorImage, { maxAge: '0' });
151
+ }
152
+ });
153
+ };
154
+
155
+ const processImage = (src, extension, dest, params) => {
156
+ return new Promise((resolve, reject) => {
157
+ const {
158
+ imageFilter,
159
+ w: width,
160
+ h: height,
161
+ t: top,
162
+ l: left,
163
+ q: quality,
164
+ f: format,
165
+ m: mode,
166
+ r: rotate,
167
+ p: progressive,
168
+ g: greyscale,
169
+ b: blur,
170
+ a: transparency,
171
+ n: negative,
172
+ s: sharpen,
173
+ } = params;
174
+
175
+ const dimensions = { top, left };
176
+ if (width) {
177
+ dimensions.width = width;
178
+ }
179
+ if (height) {
180
+ dimensions.height = height;
181
+ }
182
+
183
+ const pipeline = sharp().timeout({ seconds: 8 });
184
+ if (rotate) {
185
+ pipeline.rotate(rotate);
186
+ }
187
+ if (imageFilter === 'resize') {
188
+ pipeline.resize({ ...dimensions, fit: mode, withoutEnlargement: true });
189
+ }
190
+ if (imageFilter === 'crop') {
191
+ pipeline.extract(dimensions);
192
+ }
193
+
194
+ if (sharpen) {
195
+ pipeline.sharpen(sharpen);
196
+ }
197
+ if (blur) {
198
+ pipeline.blur(blur);
199
+ }
200
+ if (greyscale) {
201
+ pipeline.greyscale();
202
+ }
203
+ if (transparency) {
204
+ pipeline.ensureAlpha();
205
+ } else {
206
+ pipeline.removeAlpha();
207
+ }
208
+ if (negative) {
209
+ pipeline.negate();
210
+ }
211
+
212
+ // output stream
213
+ const out = fs.createWriteStream(dest);
214
+ out.on('close', () => {
215
+ resolve(dest);
216
+ });
217
+
218
+ out.on('error', (error) => {
219
+ reject(error);
220
+ });
221
+
222
+ pipeline[format || EXTENSIONS[extension]]({ quality, progressive: !!progressive, force: true });
223
+
224
+ // run the pipeline
225
+ src.pipe(pipeline).pipe(out);
226
+ });
227
+ };
228
+
229
+ module.exports = {
230
+ isImageAccepted,
231
+ isImageRequest,
232
+ processAndRespond,
233
+ processImage,
234
+ EXTENSIONS,
235
+ };
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-console */
2
2
  const fs = require('fs');
3
+ const path = require('path');
3
4
  const cloneDeep = require('lodash/cloneDeep');
4
5
  const dayjs = require('@abtnode/util/lib/dayjs');
5
6
  const JWT = require('@arcblock/jwt');
@@ -41,6 +42,7 @@ const { createDownloadLogStream } = require('@abtnode/core/lib/util/log');
41
42
  const { BlockletStatus } = require('@blocklet/constant');
42
43
 
43
44
  const { checkAdminPermission } = require('../middlewares/check-permission');
45
+ const { isImageAccepted, isImageRequest, processAndRespond } = require('../libs/image');
44
46
 
45
47
  const polishBlocklet = (doc) => {
46
48
  const res = cloneDeep(doc);
@@ -110,6 +112,7 @@ module.exports = {
110
112
  cacheError
111
113
  );
112
114
 
115
+ const cacheDir = path.join(node.dataDirs.cache, 'services', 'image');
113
116
  server.get(`${prefix}${USER_AVATAR_PATH_PREFIX}/:fileName`, async (req, res) => {
114
117
  const sendOptions = { maxAge: '1y' };
115
118
 
@@ -131,7 +134,14 @@ module.exports = {
131
134
  return;
132
135
  }
133
136
 
134
- res.sendFile(avatarFile, sendOptions);
137
+ if (isImageAccepted(req) && isImageRequest(req)) {
138
+ const appDir = path.join(cacheDir, blocklet.appPid);
139
+ processAndRespond(req, res, appDir, () =>
140
+ Promise.resolve([fs.createReadStream(avatarFile), path.extname(avatarFile).slice(1)])
141
+ );
142
+ } else {
143
+ res.sendFile(avatarFile, sendOptions);
144
+ }
135
145
  } catch (err) {
136
146
  logger.error('failed to send user avatar', { fileName: req.params.fileName, error: err });
137
147
  res.status(500).send(err.message);
@@ -0,0 +1,61 @@
1
+ // A simple image service based on sharp
2
+ // @link https://sharp.pixelplumbing.com/
3
+ const http = require('http');
4
+ const path = require('path');
5
+
6
+ const logger = require('@abtnode/logger')(require('../../../package.json').name);
7
+
8
+ const { isImageAccepted, isImageRequest, processAndRespond, EXTENSIONS } = require('../../libs/image');
9
+
10
+ const createImageService = ({ node }) => {
11
+ const getUpstreamImage = (req) => {
12
+ return new Promise((resolve, reject) => {
13
+ const upstream = new URL(req.url, req.target);
14
+ http
15
+ .request(
16
+ {
17
+ hostname: upstream.hostname,
18
+ port: upstream.port || 80,
19
+ path: upstream.pathname + upstream.search,
20
+ method: req.method,
21
+ headers: req.headers,
22
+ },
23
+ (res) => {
24
+ logger.info('image service upstream response:', {
25
+ filter: req.imageFilter,
26
+ headers: res.headers,
27
+ status: res.statusCode,
28
+ });
29
+
30
+ const [type, extension] = (res.headers['content-type'] || '').split('/');
31
+ if (type !== 'image') {
32
+ reject(new Error('upstream response is not an image'));
33
+ return;
34
+ }
35
+ if (extension !== EXTENSIONS[path.extname(req.path).slice(1)]) {
36
+ reject(new Error('upstream response type and file extension mismatch'));
37
+ return;
38
+ }
39
+
40
+ resolve([res, extension]);
41
+ }
42
+ )
43
+ .on('error', reject)
44
+ .end();
45
+ });
46
+ };
47
+
48
+ const cacheDir = path.join(node.dataDirs.cache, 'services', 'image');
49
+ const processImage = (req, res) => {
50
+ const appDir = path.join(cacheDir, req.getBlockletDid());
51
+ processAndRespond(req, res, appDir, getUpstreamImage);
52
+ };
53
+
54
+ return {
55
+ isImageAccepted,
56
+ isImageRequest,
57
+ processImage,
58
+ };
59
+ };
60
+
61
+ module.exports = createImageService;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "files": {
3
3
  "main.css": "/.well-known/service/static/static/css/main.7ea79dc8.css",
4
- "main.js": "/.well-known/service/static/static/js/main.82cb8447.js",
4
+ "main.js": "/.well-known/service/static/static/js/main.1a8e765b.js",
5
5
  "static/js/4716.a1240199.chunk.js": "/.well-known/service/static/static/js/4716.a1240199.chunk.js",
6
6
  "static/js/4359.5ed52fe3.chunk.js": "/.well-known/service/static/static/js/4359.5ed52fe3.chunk.js",
7
7
  "static/js/1255.0e8a8a45.chunk.js": "/.well-known/service/static/static/js/1255.0e8a8a45.chunk.js",
@@ -89,7 +89,7 @@
89
89
  "router-template-styles/styles.css": "/.well-known/service/static/router-template-styles/styles.css",
90
90
  "index.html": "/.well-known/service/static/index.html",
91
91
  "main.7ea79dc8.css.map": "/.well-known/service/static/static/css/main.7ea79dc8.css.map",
92
- "main.82cb8447.js.map": "/.well-known/service/static/static/js/main.82cb8447.js.map",
92
+ "main.1a8e765b.js.map": "/.well-known/service/static/static/js/main.1a8e765b.js.map",
93
93
  "4716.a1240199.chunk.js.map": "/.well-known/service/static/static/js/4716.a1240199.chunk.js.map",
94
94
  "4359.5ed52fe3.chunk.js.map": "/.well-known/service/static/static/js/4359.5ed52fe3.chunk.js.map",
95
95
  "1255.0e8a8a45.chunk.js.map": "/.well-known/service/static/static/js/1255.0e8a8a45.chunk.js.map",
@@ -157,6 +157,6 @@
157
157
  },
158
158
  "entrypoints": [
159
159
  "static/css/main.7ea79dc8.css",
160
- "static/js/main.82cb8447.js"
160
+ "static/js/main.1a8e765b.js"
161
161
  ]
162
162
  }
package/build/index.html CHANGED
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"/><meta name="theme-color" content="#000000"/><title>Blocklet Service</title><link rel="manifest" href="/.well-known/service/manifest.json"/><script src="/.well-known/service/api/env"></script><script src="/__blocklet__.js"></script><script defer="defer" src="/.well-known/service/static/static/js/main.82cb8447.js"></script><link href="/.well-known/service/static/static/css/main.7ea79dc8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"/><meta name="theme-color" content="#000000"/><title>Blocklet Service</title><link rel="manifest" href="/.well-known/service/manifest.json"/><script src="/.well-known/service/api/env"></script><script src="/__blocklet__.js"></script><script defer="defer" src="/.well-known/service/static/static/js/main.1a8e765b.js"></script><link href="/.well-known/service/static/static/css/main.7ea79dc8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>