Setting the file. One moment.
Import Media · Wix Harmony Editor · wix/skills · Skills Docs
ContentsBack to the top of the page scripts/copy/import-media.mjs
scripts/copy/ import-media.mjs
JavaScript · 95 lines · 7 KB
14 // Source URLs come from the page being copied, so Wix is never asked to fetch them: this script
15 // downloads each image itself (https only, no credentials or custom port; the host must resolve to
16 // public addresses, the connection is pinned to the address it validated, every redirect hop is
17 // re-checked, the size is capped at 10 MB, the non-resumable upload limit) and uploads the bytes through Media's Generate File Upload URL.
18 // A URL that fails a check is reported as an import error, not fetched. There is no fallback to
19 // Media's server-side `files/import`: that would hand an untrusted URL to Wix to fetch.
20 import dns from 'node:dns/promises' ;
21 import https from 'node:https' ;
22 import fs from 'node:fs' ;
23 import path from 'node:path' ;
24 import { isGlobalUnicastAddress, isPrivateHostname, MAX_UPLOAD_BYTES } from './public-address.mjs' ;
25 import { importPageImages } from './media-import.mjs' ;
26
27 const args = process.argv. slice ( 2 );
28 const opt = ( n , d ) => { const i = args. indexOf ( `--${ n }` ); return i >= 0 ? args[i + 1 ] : d; };
29 const [ projectDir , descFile ] = args. filter (( a , i ) => ! a. startsWith ( '--' ) && ! (i > 0 && args[i - 1 ] === '--ledger' ));
30 if ( ! projectDir || ! descFile) { console. error ( 'usage: import-media.mjs <projectDir> <page.desc.json> [--ledger file]' ); process. exit ( 2 ); }
31 const env = Object. fromEntries (fs. readFileSync (path. join (projectDir, 'config/wix.env' ), 'utf8' ). split ( ' \n ' ). filter (( l ) => l. includes ( '=' )). map (( l ) => [l. slice ( 0 , l. indexOf ( '=' )), l. slice (l. indexOf ( '=' ) + 1 ). trim ()]));
32 const auth = / ^ IST \. / . test (env. WIX_AUTH_TOKEN ) ? env. WIX_AUTH_TOKEN : `Bearer ${ env . WIX_AUTH_TOKEN }` ;
33 const headers = { Authorization: auth, 'Content-Type' : 'application/json' , 'wix-site-id' : env. WIX_SITE_ID };
34 const ledgerFile = opt ( 'ledger' , path. join (path. dirname (descFile), 'media-ledger.json' ));
35 const ledger = fs. existsSync (ledgerFile) ? JSON . parse (fs. readFileSync (ledgerFile, 'utf8' )) : {};
36 const desc = JSON . parse (fs. readFileSync (descFile, 'utf8' ));
37 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
38
39 // One GET pinned to an address we already validated; TLS still verifies the certificate against the hostname.
40 function pinnedGet ( url , { address , family }) {
41 const u = new URL (url);
42 return new Promise (( resolve , reject ) => {
43 let settled = false ;
44 const done = ( fn , v ) => { if ( ! settled) { settled = true ; fn (v); } };
45 const req = https. request ({
46 protocol: 'https:' , hostname: u.hostname, port: 443 , path: `${ u . pathname }${ u . search }` , method: 'GET' , servername: u.hostname, timeout: 20000 ,
47 headers: { Accept: 'image/*,*/*' , 'Accept-Encoding' : 'identity' , 'User-Agent' : 'wix-harmony-copy-import-media' },
48 lookup : ( h , o , cb ) => (o && o.all ? cb ( null , [{ address, family }]) : cb ( null , address, family)),
49 }, ( res ) => {
50 const status = res.statusCode || 0 ;
51 if (status >= 300 && status < 400 ) { res. resume (); return done (resolve, { status, location: res.headers.location || '' , body: Buffer. alloc ( 0 ) }); }
52 const declared = Number (res.headers[ 'content-length' ]);
53 if (Number. isFinite (declared) && declared > MAX_UPLOAD_BYTES ) { res. destroy (); return done (reject, new Error ( 'response-too-large' )); }
54 const chunks = []; let total = 0 ;
55 res. on ( 'data' , ( c ) => { total += c. length ; if (total > MAX_UPLOAD_BYTES ) { res. destroy (); return done (reject, new Error ( 'response-too-large' )); } chunks. push (c); });
56 res. on ( 'end' , () => done (resolve, { status, type: String (res.headers[ 'content-type' ] || '' ). split ( ';' )[ 0 ]. trim (). toLowerCase (), body: Buffer. concat (chunks) }));
57 res. on ( 'error' , ( e ) => done (reject, e));
58 });
59 req. on ( 'timeout' , () => { req. destroy (); done (reject, new Error ( 'timeout' )); });
60 req. on ( 'error' , ( e ) => done (reject, e));
61 req. end ();
62 });
63 }
64 // { ok: true, bytes, contentType } | { ok: false, unsafe, reason }
65 async function fetchPublicImage ( raw ) {
66 let current = String (raw || '' ). trim ();
67 for ( let hop = 0 ; hop <= 3 ; hop ++ ) {
68 let u; try { u = new URL (current); } catch { return { ok: false , unsafe: true , reason: 'unparseable-url' }; }
69 if (u.protocol !== 'https:' ) return { ok: false , unsafe: true , reason: 'not-https' };
70 if (u.username || u.password) return { ok: false , unsafe: true , reason: 'embedded-credentials' };
71 if (u.port && u.port !== '443' ) return { ok: false , unsafe: true , reason: 'non-standard-port' };
72 if ( isPrivateHostname (u.hostname)) return { ok: false , unsafe: true , reason: 'private-or-loopback-host' };
73 let addrs; try { addrs = await dns. lookup (u.hostname, { all: true }); } catch { return { ok: false , unsafe: false , reason: 'host-not-resolvable' }; }
74 if ( ! addrs. length || addrs. some (( x ) => ! isGlobalUnicastAddress (x.address))) return { ok: false , unsafe: true , reason: 'resolves-to-private-address' };
75 let res; try { res = await pinnedGet (current, { address: addrs[ 0 ].address, family: addrs[ 0 ].family || (addrs[ 0 ].address. includes ( ':' ) ? 6 : 4 ) }); } catch (e) { return { ok: false , unsafe: false , reason: e.message === 'response-too-large' ? 'response-too-large' : 'unreachable' }; }
76 if (res.status >= 300 && res.status < 400 ) {
77 if ( ! res.location) return { ok: false , unsafe: true , reason: 'redirect-without-location' };
78 try { current = new URL (res.location, current). toString (); } catch { return { ok: false , unsafe: true , reason: 'redirect-unparseable-url' }; }
79 continue ;
80 }
81 if (res.status < 200 || res.status >= 300 ) return { ok: false , unsafe: false , reason: `http-${ res . status }` };
82 if ( ! res.body. length ) return { ok: false , unsafe: false , reason: 'empty-image' };
83 return { ok: true , bytes: res.body, contentType: res.type };
84 }
85 return { ok: false , unsafe: true , reason: 'too-many-redirects' };
86 }
87
88 const results = await importPageImages ({
89 desc, ledger, headers, sleep, fetchImage: fetchPublicImage, wixFetch: fetch,
90 saveLedger : ( value ) => fs. writeFileSync (ledgerFile, JSON . stringify (value, null , 1 )),
91 });
92 fs. writeFileSync (descFile, JSON . stringify (desc, null , 1 ));
93 console. log ( JSON . stringify (results));
94 const failed = results. filter (( r ) => ! r.uri || r.error);
95 if (failed. length ) { console. error ( `import failed or not ready for: ${ failed . map (( r ) => `${ r . key } (${ r . error || 'no uri'})` ). join ( ', ' ) }` ); process. exit ( 1 ); }