Setting the file. One moment.
Upload Media · Wix App · wix/skills · Skills Docs
ContentsBack to the top of the page function resolveCrop
— line 222
This file
Number 1.205
Position 205 of 205
Type JavaScript
Size 15 KB
Lines 432 scripts/ upload-media.cjs
JavaScript · 432 lines · 15 KB
16 * points at a private, loopback or link-local address. `--size` prints each image's width and height. `--crop` applies to the
17 * image after it; each value is pixels (`156`) or a percentage of the image (`12%`), so
18 * repeat `--crop <area> <screenshot>` once per photo. Cropping uses `sharp`, installed
19 * under /tmp/user-images when the app doesn't have it, so the project is never touched.
20 *
21 * Uploads authenticate with the logged-in Wix CLI (`wix token --json`): Generate File
22 * Upload URL, then a PUT of the bytes. One JSON report is printed; each `uploads[].image`
23 * is the `{ uri, url }` to put in `defaultProps` (add `alt` yourself).
24 *
25 * Exit codes: 0 every file uploaded (or sized), 1 at least one failed, 2 the script could
26 * not run (bad arguments, or no CLI token).
27 */
28
29 const fs = require ( 'fs' );
30 const net = require ( 'net' );
31 const path = require ( 'path' );
32 const { lookup } = require ( 'dns' ).promises;
33 const { createRequire } = require ( 'module' );
34 const { execFileSync } = require ( 'child_process' );
35
36 const GENERATE_UPLOAD_URL = 'https://www.wixapis.com/site-media/v1/files/generate-upload-url' ;
37 const TOKEN_TIMEOUT_MS = 30_000 ;
38 const SHARP_INSTALL_TIMEOUT_MS = 180_000 ;
39 const DOWNLOAD_TIMEOUT_MS = 30_000 ;
40 const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 ;
41 const MAX_REDIRECTS = 3 ;
42 const WORK_DIR = '/tmp/user-images' ;
43 const USAGE = `Usage: node <SKILL_ROOT>/scripts/upload-media.cjs --size <image>
44 node <SKILL_ROOT>/scripts/upload-media.cjs [--crop <left,top,width,height>] <file|url> ...` ;
45 const MIME_TYPES = {
46 '.avif' : 'image/avif' ,
47 '.gif' : 'image/gif' ,
48 '.jpeg' : 'image/jpeg' ,
49 '.jpg' : 'image/jpeg' ,
50 '.png' : 'image/png' ,
51 '.webp' : 'image/webp' ,
52 };
53 const CROP_VALUE = / ^ ( \d + (?: \. \d + ) ? )(% ? ) $ / ;
54
55 function mimeTypeOf ( file ) {
56 return MIME_TYPES [path. extname (file). toLowerCase ()];
57 }
58
59 function isUrl ( source ) {
60 return / ^ https ? : \/\/ / i . test (source);
61 }
62
63 function extensionForMime ( mimeType ) {
64 const match = Object. entries ( MIME_TYPES ). find (([, type ]) => type === mimeType);
65 return match?.[ 0 ];
66 }
67
68 /** False for addresses the sandbox must not fetch: private, loopback, link-local, CGNAT, multicast, reserved. */
69 function isPublicAddress ( address ) {
70 if (net. isIPv4 (address)) {
71 const [ a , b ] = address. split ( '.' ). map (Number);
72 return ! (
73 a === 0 ||
74 a === 10 ||
75 a === 127 ||
76 a >= 224 ||
77 (a === 100 && b >= 64 && b <= 127 ) ||
78 (a === 169 && b === 254 ) ||
79 (a === 172 && b >= 16 && b <= 31 ) ||
80 (a === 192 && (b === 0 || b === 168 )) ||
81 (a === 198 && (b === 18 || b === 19 ))
82 );
83 }
84 const groups = ipv6Groups (address);
85 if (groups. length !== 8 || groups. some (( group ) => ! (group >= 0 && group <= 0xffff ))) return false ;
86 const tail = `${ groups [ 6 ] >> 8 }.${ groups [ 6 ] & 255 }.${ groups [ 7 ] >> 8 }.${ groups [ 7 ] & 255 }` ;
87 const zeros = ( count ) => groups. slice ( 0 , count). every (( group ) => group === 0 );
88 // IPv4-mapped (::ffff:a.b.c.d, also written ::ffff:7f00:1), IPv4-compatible (::a.b.c.d)
89 // and NAT64 (64:ff9b::a.b.c.d) addresses carry an IPv4 address: check that one.
90 if ( zeros ( 5 ) && groups[ 5 ] === 0xffff ) return isPublicAddress (tail);
91 if ( zeros ( 6 )) return groups[ 6 ] === 0 && groups[ 7 ] <= 1 ? false : isPublicAddress (tail);
92 if (
93 groups[ 0 ] === 0x64 &&
94 groups[ 1 ] === 0xff9b &&
95 groups. slice ( 2 , 6 ). every (( group ) => group === 0 )
96 )
97 return isPublicAddress (tail);
98 return ! (
99 (groups[ 0 ] & 0xfe00 ) === 0xfc00 ||
100 (groups[ 0 ] & 0xffc0 ) === 0xfe80 ||
101 (groups[ 0 ] & 0xff00 ) === 0xff00 ||
102 (groups[ 0 ] === 0x2001 && groups[ 1 ] === 0x0db8 )
103 );
104 }
105
106 /** The eight 16-bit groups of an IPv6 address, with `::` expanded and a dotted IPv4 tail split in two. */
107 function ipv6Groups ( address ) {
108 let text = address
109 . toLowerCase ()
110 . replace ( / ^ \[ | \] $ / g , '' )
111 . split ( '%' )[ 0 ];
112 const dotted = /( \d + ) \. ( \d + ) \. ( \d + ) \. ( \d + ) $ / . exec (text);
113 if (dotted) {
114 const [ a , b , c , d ] = dotted. slice ( 1 ). map (Number);
115 text = `${ text . slice ( 0 , dotted . index ) }${ (( a << 8 ) | b ). toString ( 16 ) }:${ (( c << 8 ) | d ). toString ( 16 ) }` ;
116 }
117 const [ head , tail ] = text. split ( '::' );
118 const left = head ? head. split ( ':' ) : [];
119 if (tail === undefined ) return left. map (( group ) => parseInt (group, 16 ));
120 const right = tail ? tail. split ( ':' ) : [];
121 const fill = Array (Math. max ( 0 , 8 - left. length - right. length )). fill ( '0' );
122 return [ ... left, ... fill, ... right]. map (( group ) => parseInt (group, 16 ));
123 }
124
125 /** Returns the https URL to fetch, or throws when it may reach a non-public host. */
126 async function checkedUrl ( url , lookupImpl ) {
127 const parsed = new URL (url);
128 if (parsed.protocol === 'http:' ) parsed.protocol = 'https:' ;
129 if (parsed.protocol !== 'https:' ) throw new Error ( `Only https image URLs are downloaded` );
130 if (parsed.username || parsed.password)
131 throw new Error ( 'Image URLs with credentials are refused' );
132 if (parsed.port && parsed.port !== '443' )
133 throw new Error ( 'Image URLs on a non-default port are refused' );
134 const host = parsed.hostname. replace ( / ^ \[ | \] $ / g , '' );
135 const addresses = net. isIP (host) ? [{ address: host }] : await lookupImpl (host, { all: true });
136 if (addresses. length === 0 || addresses. some (({ address }) => ! isPublicAddress (address))) {
137 throw new Error ( `Refusing to download from ${ parsed . hostname }: it is not a public address` );
138 }
139 return parsed.href;
140 }
141
142 async function downloadToFile ( url , index , fetchImpl = fetch, lookupImpl = lookup) {
143 // Redirects are followed by hand so each hop is checked before it is fetched.
144 let current = url;
145 let response;
146 for ( let hop = 0 ; ; hop += 1 ) {
147 current = await checkedUrl (current, lookupImpl);
148 response = await fetchImpl (current, {
149 redirect: 'manual' ,
150 signal: AbortSignal. timeout ( DOWNLOAD_TIMEOUT_MS ),
151 });
152 const location =
153 response.status >= 300 && response.status < 400 && response.headers. get ( 'location' );
154 if ( ! location) break ;
155 if (hop === MAX_REDIRECTS )
156 throw new Error ( `Download redirected more than ${ MAX_REDIRECTS } times` );
157 current = new URL (location, current).href;
158 }
159 if ( ! response.ok) throw new Error ( `Download failed, HTTP ${ response . status }` );
160 const contentType = (response.headers. get ( 'content-type' ) ?? '' ). split ( ';' )[ 0 ]. trim ();
161 const extension =
162 extensionForMime (contentType) ?? path. extname ( new URL (url).pathname). toLowerCase ();
163 if ( ! MIME_TYPES [extension]) {
164 throw new Error ( `Download is not a supported image (${ contentType || 'unknown type'})` );
165 }
166 if ( Number (response.headers. get ( 'content-length' )) > MAX_DOWNLOAD_BYTES ) {
167 throw new Error ( 'Download is larger than 25 MB' );
168 }
169 const bytes = Buffer. from ( await response. arrayBuffer ());
170 if (bytes. length > MAX_DOWNLOAD_BYTES ) throw new Error ( 'Download is larger than 25 MB' );
171 const name = path
172 . basename ( new URL (url).pathname, path. extname ( new URL (url).pathname))
173 . replace ( / [ ^ a-z0-9] + / gi , '-' )
174 . replace ( / ^ - | - $ / g , '' );
175 fs. mkdirSync ( WORK_DIR , { recursive: true });
176 const output = path. join ( WORK_DIR , `${ name || 'image'}-${ index }${ extension }` );
177 fs. writeFileSync (output, bytes);
178 return output;
179 }
180
181 function localSource ( file , index , fetchImpl , lookupImpl ) {
182 return isUrl (file) ? downloadToFile (file, index, fetchImpl, lookupImpl) : file;
183 }
184
185 function parseCrop ( spec ) {
186 const parts = String (spec)
187 . split ( ',' )
188 . map (( part ) => part. trim ());
189 const values = parts. map (( part ) => CROP_VALUE . exec (part));
190 if (parts. length !== 4 || values. some (( value ) => ! value)) {
191 throw new Error ( `Invalid --crop "${ spec }"; use left,top,width,height in pixels or %` );
192 }
193 const [ left , top , width , height ] = values. map (([, amount , percent ]) => ({
194 amount: Number (amount),
195 percent: percent === '%' ,
196 }));
197 return { left, top, width, height };
198 }
199
200 function parseArgs ( argv ) {
201 if (argv[ 0 ] === '--size' ) {
202 if (argv. length < 2 ) throw new Error ( '--size needs at least one image' );
203 return { mode: 'size' , files: argv. slice ( 1 ) };
204 }
205 const entries = [];
206 for ( let i = 0 ; i < argv. length ; i += 1 ) {
207 if (argv[i] === '--crop' ) {
208 const [ spec , file ] = [argv[i + 1 ], argv[i + 2 ]];
209 if (spec === undefined || file === undefined ) {
210 throw new Error ( '--crop needs an area and a file: --crop <left,top,width,height> <file>' );
211 }
212 entries. push ({ file, crop: parseCrop (spec) });
213 i += 2 ;
214 } else {
215 entries. push ({ file: argv[i] });
216 }
217 }
218 if (entries. length === 0 ) throw new Error ( 'No files specified.' );
219 return { mode: 'upload' , entries };
220 }
221
222 function resolveCrop ( crop , { width , height }) {
223 const toPixels = ({ amount , percent }, total ) =>
224 Math. round (percent ? (amount / 100 ) * total : amount);
225 const area = {
226 left: toPixels (crop.left, width),
227 top: toPixels (crop.top, height),
228 width: toPixels (crop.width, width),
229 height: toPixels (crop.height, height),
230 };
231 if (
232 area.width < 1 ||
233 area.height < 1 ||
234 area.left + area.width > width ||
235 area.top + area.height > height
236 ) {
237 throw new Error (
238 `Crop ${ area . left },${ area . top },${ area . width },${ area . height } falls outside the ${ width }x${ height } image` ,
239 );
240 }
241 return area;
242 }
243
244 function requireSharpFrom ( directory ) {
245 return createRequire (path. join (directory, 'noop.js' ))( 'sharp' );
246 }
247
248 function loadSharp () {
249 for ( const directory of [process. cwd (), WORK_DIR ]) {
250 try {
251 return requireSharpFrom (directory);
252 } catch {
253 // Not installed there.
254 }
255 }
256 fs. mkdirSync ( WORK_DIR , { recursive: true });
257 execFileSync (
258 'npm' ,
259 [
260 'install' ,
261 '--prefix' ,
262 WORK_DIR ,
263 '--no-save' ,
264 '--no-audit' ,
265 '--no-fund' ,
266 '--loglevel=error' ,
267 'sharp' ,
268 ],
269 { stdio: 'ignore' , timeout: SHARP_INSTALL_TIMEOUT_MS },
270 );
271 return requireSharpFrom ( WORK_DIR );
272 }
273
274 function lazySharp ( load = loadSharp) {
275 let sharp;
276 return () => {
277 sharp ??= load ();
278 return sharp;
279 };
280 }
281
282 async function cropToFile ( file , crop , index , getSharp ) {
283 const sharp = getSharp ();
284 const area = resolveCrop (crop, await sharp (file). metadata ());
285 const name = path
286 . parse (file)
287 .name. replace ( / [ ^ a-z0-9] + / gi , '-' )
288 . replace ( / ^ - | - $ / g , '' );
289 fs. mkdirSync ( WORK_DIR , { recursive: true });
290 const output = path. join ( WORK_DIR , `${ name || 'image'}-crop-${ index }.webp` );
291 await sharp (file). extract (area). webp ({ quality: 90 }). toFile (output);
292 return output;
293 }
294
295 function getCliToken () {
296 const attempts = [
297 [ 'wix' , [ 'token' , '--json' ]],
298 [ 'npx' , [ 'wix' , 'token' , '--json' ]],
299 ];
300 for ( const [ command , args ] of attempts) {
301 try {
302 const output = execFileSync (command, args, { encoding: 'utf8' , timeout: TOKEN_TIMEOUT_MS });
303 const { accessToken } = JSON . parse (output);
304 if (accessToken) return accessToken;
305 } catch {
306 // Try the next way of running the CLI.
307 }
308 }
309 throw new Error ( 'Could not get a token from `wix token --json`; is the Wix CLI logged in?' );
310 }
311
312 async function readError ( response ) {
313 const body = await response. text ();
314 return `HTTP ${ response . status }${ body ? `: ${ body . slice ( 0 , 300 ) }` : ''}` ;
315 }
316
317 async function uploadFile ( file , { token , fetchImpl = fetch }) {
318 const mimeType = mimeTypeOf (file);
319 if ( ! mimeType) {
320 throw new Error ( `Unsupported file type; use one of ${ Object . keys ( MIME_TYPES ). join ( ', ' ) }` );
321 }
322 const fileName = path. basename (file);
323 const bytes = fs. readFileSync (file);
324
325 const generated = await fetchImpl ( GENERATE_UPLOAD_URL , {
326 method: 'POST' ,
327 headers: { Authorization: token, 'Content-Type' : 'application/json' },
328 body: JSON . stringify ({ mimeType, fileName }),
329 });
330 if ( ! generated.ok)
331 throw new Error ( `Generate File Upload URL failed, ${ await readError ( generated ) }` );
332 const { uploadUrl } = await generated. json ();
333
334 const uploaded = await fetchImpl ( `${ uploadUrl }?filename=${ encodeURIComponent ( fileName ) }` , {
335 method: 'PUT' ,
336 headers: { 'Content-Type' : mimeType },
337 body: bytes,
338 });
339 if ( ! uploaded.ok) throw new Error ( `Upload failed, ${ await readError ( uploaded ) }` );
340 const { file : descriptor } = await uploaded. json ();
341 if ( ! descriptor?.id || ! descriptor?.url) throw new Error ( 'Upload response has no file id or url' );
342
343 return { uri: descriptor.id, url: descriptor.url };
344 }
345
346 async function uploadAll ( entries , { token , fetchImpl , lookupImpl , getSharp = lazySharp () }) {
347 const uploads = [];
348 for ( const [ index , { file , crop }] of entries. entries ()) {
349 const result = { file, ... (crop ? { crop: formatCrop (crop) } : {}) };
350 try {
351 const local = await localSource (file, index + 1 , fetchImpl, lookupImpl);
352 const source = crop ? await cropToFile (local, crop, index + 1 , getSharp) : local;
353 uploads. push ({
354 ... result,
355 status: 'uploaded' ,
356 image: await uploadFile (source, { token, fetchImpl }),
357 });
358 } catch (error) {
359 uploads. push ({ ... result, status: 'failed' , error: error.message });
360 }
361 }
362 const failed = uploads. filter (( upload ) => upload.status === 'failed' ). length ;
363 return {
364 summary: { uploaded: uploads. length - failed, failed },
365 uploads,
366 exitCode: failed ? 1 : 0 ,
367 };
368 }
369
370 async function readSizes ( files , { getSharp = lazySharp (), fetchImpl , lookupImpl } = {}) {
371 const sizes = [];
372 for ( const [ index , file ] of files. entries ()) {
373 try {
374 const local = await localSource (file, index + 1 , fetchImpl, lookupImpl);
375 const { width , height } = await getSharp ()(local). metadata ();
376 sizes. push ({ file, width, height });
377 } catch (error) {
378 sizes. push ({ file, error: error.message });
379 }
380 }
381 return { sizes, exitCode: sizes. some (( size ) => size.error) ? 1 : 0 };
382 }
383
384 function formatCrop ({ left , top , width , height }) {
385 return [left, top, width, height]
386 . map (({ amount , percent }) => `${ amount }${ percent ? '%' : ''}` )
387 . join ( ',' );
388 }
389
390 async function main () {
391 let options;
392 try {
393 options = parseArgs (process.argv. slice ( 2 ));
394 } catch (error) {
395 console. error ( `${ error . message } \n ${ USAGE }` );
396 process. exit ( 2 );
397 }
398 if (options.mode === 'size' ) {
399 const { exitCode , ... report } = await readSizes (options.files);
400 console. log ( JSON . stringify (report));
401 process. exit (exitCode);
402 }
403 let token;
404 try {
405 token = getCliToken ();
406 } catch (error) {
407 console. log (
408 JSON . stringify ({
409 summary: { uploaded: 0 , failed: options.entries. length },
410 error: error.message,
411 }),
412 );
413 process. exit ( 2 );
414 }
415 const { exitCode , ... report } = await uploadAll (options.entries, { token });
416 console. log ( JSON . stringify (report));
417 process. exit (exitCode);
418 }
419
420 module . exports = {
421 uploadFile,
422 uploadAll,
423 readSizes,
424 downloadToFile,
425 isPublicAddress,
426 mimeTypeOf,
427 parseArgs,
428 parseCrop,
429 resolveCrop,
430 };
431
432 if (require.main === module ) main ();