@materializecms/server 0.2.0 → 0.4.0
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/CHANGELOG.md +23 -0
- package/LICENSE +1 -1
- package/package.json +4 -4
- package/src/ai.d.ts +35 -1
- package/src/ai.js +64 -6
- package/src/app.js +286 -17
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/mail.d.ts +27 -0
- package/src/mail.js +170 -0
- package/src/offsite.d.ts +52 -0
- package/src/offsite.js +230 -0
- package/src/remote-storage.d.ts +47 -0
- package/src/remote-storage.js +264 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
export class RemoteError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** A human reason for an HTTP failure, the same for both kinds of storage. */
|
|
14
|
+
function failure(what, status, detail = '') {
|
|
15
|
+
const why = status === 401 || status === 403
|
|
16
|
+
? 'accesso rifiutato: controlla le credenziali e i permessi'
|
|
17
|
+
: status === 404
|
|
18
|
+
? 'non trovato: controlla l’indirizzo, il bucket o la cartella'
|
|
19
|
+
: status === 409
|
|
20
|
+
? 'una cartella del percorso non esiste: crea almeno quella che contiene la cartella dei backup'
|
|
21
|
+
: status === 507 || /QuotaExceeded|InsufficientStorage/i.test(detail)
|
|
22
|
+
? 'spazio esaurito'
|
|
23
|
+
: `il servizio ha risposto ${status}`;
|
|
24
|
+
const code = /<Code>([^<]+)<\/Code>/.exec(detail)?.[1];
|
|
25
|
+
return new RemoteError(`${what}: ${why}${code ? ` (${code})` : ''}`, status);
|
|
26
|
+
}
|
|
27
|
+
async function reach(url, init) {
|
|
28
|
+
try {
|
|
29
|
+
return await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(10 * 60_000) });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const cause = error.cause?.code;
|
|
33
|
+
throw new RemoteError(cause === 'ENOTFOUND'
|
|
34
|
+
? `Il servizio ${new URL(url).host} non si trova: controlla l’indirizzo`
|
|
35
|
+
: `Il servizio non risponde (${cause ?? error.message})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const fileBody = (file) => Readable.toWeb(createReadStream(file));
|
|
39
|
+
async function saveBody(res, file) {
|
|
40
|
+
if (!res.body)
|
|
41
|
+
throw new RemoteError('Risposta vuota');
|
|
42
|
+
try {
|
|
43
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(file));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new RemoteError(`Il download si è interrotto (${error.message}): riprova`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ---------- S3 ----------
|
|
50
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
51
|
+
const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
|
|
52
|
+
const EMPTY_HASH = sha256('');
|
|
53
|
+
/** RFC 3986 encoding, as Signature Version 4 wants it (keeps / in paths when asked). */
|
|
54
|
+
const encode = (text, slash = true) => encodeURIComponent(text)
|
|
55
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
56
|
+
.replace(slash ? /%2F/g : /$^/, '/');
|
|
57
|
+
/**
|
|
58
|
+
* A request signed with AWS Signature Version 4, which every S3-compatible service accepts. The
|
|
59
|
+
* body is not hashed (UNSIGNED-PAYLOAD): files are streamed over HTTPS, as large as they are.
|
|
60
|
+
*/
|
|
61
|
+
export function signS3(target, method, key, query = {}, headers = {}, now = new Date(), payloadHash = 'UNSIGNED-PAYLOAD') {
|
|
62
|
+
const endpoint = new URL(target.endpoint);
|
|
63
|
+
// Amazon wants the bucket in the host name; the others accept it in the path, which needs no DNS.
|
|
64
|
+
const virtual = /\.amazonaws\.com$/i.test(endpoint.hostname);
|
|
65
|
+
const host = virtual ? `${target.bucket}.${endpoint.host}` : endpoint.host;
|
|
66
|
+
const base = endpoint.pathname.replace(/\/+$/, '');
|
|
67
|
+
// Listing the bucket: "/" on Amazon's host, "/bucket" in the path elsewhere.
|
|
68
|
+
const bucketPath = `${base}${virtual ? '' : `/${encode(target.bucket)}`}`;
|
|
69
|
+
const path = key ? `${bucketPath}/${encode(key)}` : bucketPath || '/';
|
|
70
|
+
const amzDate = now.toISOString().replace(/[-:]|\.\d{3}/g, '');
|
|
71
|
+
const day = amzDate.slice(0, 8);
|
|
72
|
+
const region = target.region || 'us-east-1';
|
|
73
|
+
const all = {
|
|
74
|
+
...Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
|
75
|
+
host,
|
|
76
|
+
'x-amz-content-sha256': payloadHash,
|
|
77
|
+
'x-amz-date': amzDate,
|
|
78
|
+
};
|
|
79
|
+
const names = Object.keys(all).sort();
|
|
80
|
+
const canonicalQuery = Object.keys(query)
|
|
81
|
+
.sort()
|
|
82
|
+
.map((k) => `${encode(k, false)}=${encode(query[k] ?? '', false)}`)
|
|
83
|
+
.join('&');
|
|
84
|
+
const canonical = [
|
|
85
|
+
method,
|
|
86
|
+
path,
|
|
87
|
+
canonicalQuery,
|
|
88
|
+
names.map((n) => `${n}:${String(all[n]).trim()}\n`).join(''),
|
|
89
|
+
names.join(';'),
|
|
90
|
+
payloadHash,
|
|
91
|
+
].join('\n');
|
|
92
|
+
const scope = `${day}/${region}/s3/aws4_request`;
|
|
93
|
+
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
|
|
94
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${target.secretAccessKey}`, day), region), 's3'), 'aws4_request');
|
|
95
|
+
const signature = createHmac('sha256', signingKey).update(toSign).digest('hex');
|
|
96
|
+
all.authorization = `AWS4-HMAC-SHA256 Credential=${target.accessKeyId}/${scope}, SignedHeaders=${names.join(';')}, Signature=${signature}`;
|
|
97
|
+
delete all.host;
|
|
98
|
+
return { url: `${endpoint.protocol}//${host}${path}${canonicalQuery ? `?${canonicalQuery}` : ''}`, headers: all };
|
|
99
|
+
}
|
|
100
|
+
const xmlValue = (xml, tag) => new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] ?? '';
|
|
101
|
+
const unescapeXml = (s) => s
|
|
102
|
+
.replace(/</g, '<')
|
|
103
|
+
.replace(/>/g, '>')
|
|
104
|
+
.replace(/"/g, '"')
|
|
105
|
+
.replace(/'/g, "'")
|
|
106
|
+
.replace(/&/g, '&');
|
|
107
|
+
export function createS3Storage(target, prefix = '') {
|
|
108
|
+
const full = (key) => `${prefix.replace(/^\/+|\/+$/g, '')}${prefix.trim() ? '/' : ''}${key}`;
|
|
109
|
+
const call = async (what, method, key, opts = {}) => {
|
|
110
|
+
const signed = signS3(target, method, key, opts.query, opts.headers, new Date(), opts.payload);
|
|
111
|
+
const res = await reach(signed.url, {
|
|
112
|
+
method,
|
|
113
|
+
headers: signed.headers,
|
|
114
|
+
body: opts.body,
|
|
115
|
+
...(opts.body instanceof ReadableStream ? { duplex: 'half' } : {}),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok && !(method === 'DELETE' && res.status === 404))
|
|
118
|
+
throw failure(what, res.status, await res.text());
|
|
119
|
+
return res;
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
async put(key, file) {
|
|
123
|
+
const { size } = await stat(file);
|
|
124
|
+
await call(`Caricamento di ${key}`, 'PUT', full(key), {
|
|
125
|
+
headers: { 'content-length': String(size), 'content-type': 'application/octet-stream' },
|
|
126
|
+
body: fileBody(file),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
async putText(key, text) {
|
|
130
|
+
const body = Buffer.from(text);
|
|
131
|
+
await call(`Caricamento di ${key}`, 'PUT', full(key), {
|
|
132
|
+
headers: { 'content-length': String(body.length), 'content-type': 'text/plain; charset=utf-8' },
|
|
133
|
+
body,
|
|
134
|
+
payload: sha256(body),
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
async get(key, file) {
|
|
138
|
+
await saveBody(await call(`Download di ${key}`, 'GET', full(key), { payload: EMPTY_HASH }), file);
|
|
139
|
+
},
|
|
140
|
+
async getText(key) {
|
|
141
|
+
return (await call(`Download di ${key}`, 'GET', full(key), { payload: EMPTY_HASH })).text();
|
|
142
|
+
},
|
|
143
|
+
async list(folder) {
|
|
144
|
+
const items = [];
|
|
145
|
+
const strip = full('').length;
|
|
146
|
+
let token = '';
|
|
147
|
+
do {
|
|
148
|
+
const query = { 'list-type': '2', prefix: full(folder) };
|
|
149
|
+
if (token)
|
|
150
|
+
query['continuation-token'] = token;
|
|
151
|
+
const xml = await (await call('Elenco dei file', 'GET', '', { query, payload: EMPTY_HASH })).text();
|
|
152
|
+
for (const block of xml.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? [])
|
|
153
|
+
items.push({
|
|
154
|
+
key: unescapeXml(xmlValue(block, 'Key')).slice(strip),
|
|
155
|
+
size: Number(xmlValue(block, 'Size')),
|
|
156
|
+
modified: new Date(xmlValue(block, 'LastModified')),
|
|
157
|
+
});
|
|
158
|
+
token = xmlValue(xml, 'IsTruncated') === 'true' ? unescapeXml(xmlValue(xml, 'NextContinuationToken')) : '';
|
|
159
|
+
} while (token);
|
|
160
|
+
return items;
|
|
161
|
+
},
|
|
162
|
+
async delete(key) {
|
|
163
|
+
await call(`Eliminazione di ${key}`, 'DELETE', full(key), { payload: EMPTY_HASH });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// ---------- WebDAV ----------
|
|
168
|
+
export function createWebDavStorage(target) {
|
|
169
|
+
const root = target.url.endsWith('/') ? target.url : `${target.url}/`;
|
|
170
|
+
const auth = `Basic ${Buffer.from(`${target.user}:${target.password}`).toString('base64')}`;
|
|
171
|
+
const url = (key) => new URL(key.split('/').map(encodeURIComponent).join('/'), root).toString();
|
|
172
|
+
const made = new Set();
|
|
173
|
+
const call = async (what, method, key, init = {}) => {
|
|
174
|
+
const res = await reach(url(key), { ...init, method, headers: { authorization: auth, ...init.headers } });
|
|
175
|
+
if (!res.ok && !(method === 'DELETE' && res.status === 404))
|
|
176
|
+
throw failure(what, res.status, await res.text());
|
|
177
|
+
return res;
|
|
178
|
+
};
|
|
179
|
+
/** The folders of a path, one by one, the backup folder itself first: WebDAV does not make them. */
|
|
180
|
+
const folders = async (key) => {
|
|
181
|
+
if (!made.has('')) {
|
|
182
|
+
const res = await reach(root, { method: 'MKCOL', headers: { authorization: auth } });
|
|
183
|
+
if (!res.ok && res.status !== 405)
|
|
184
|
+
throw failure('Creazione della cartella dei backup', res.status, await res.text());
|
|
185
|
+
made.add('');
|
|
186
|
+
}
|
|
187
|
+
const parts = key.split('/').slice(0, -1);
|
|
188
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
189
|
+
const dir = `${parts.slice(0, i).join('/')}/`;
|
|
190
|
+
if (made.has(dir))
|
|
191
|
+
continue;
|
|
192
|
+
const res = await reach(url(dir), { method: 'MKCOL', headers: { authorization: auth } });
|
|
193
|
+
// 405: it exists already.
|
|
194
|
+
if (!res.ok && res.status !== 405)
|
|
195
|
+
throw failure(`Creazione della cartella ${dir}`, res.status, await res.text());
|
|
196
|
+
made.add(dir);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
async put(key, file) {
|
|
201
|
+
await folders(key);
|
|
202
|
+
const { size } = await stat(file);
|
|
203
|
+
await call(`Caricamento di ${key}`, 'PUT', key, {
|
|
204
|
+
headers: { 'content-length': String(size), 'content-type': 'application/octet-stream' },
|
|
205
|
+
body: fileBody(file),
|
|
206
|
+
duplex: 'half',
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
async putText(key, text) {
|
|
210
|
+
await folders(key);
|
|
211
|
+
await call(`Caricamento di ${key}`, 'PUT', key, {
|
|
212
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
213
|
+
body: text,
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
async get(key, file) {
|
|
217
|
+
await saveBody(await call(`Download di ${key}`, 'GET', key), file);
|
|
218
|
+
},
|
|
219
|
+
async getText(key) {
|
|
220
|
+
return (await call(`Download di ${key}`, 'GET', key)).text();
|
|
221
|
+
},
|
|
222
|
+
async list(folder) {
|
|
223
|
+
const items = [];
|
|
224
|
+
const rootPath = decodeURIComponent(new URL(root).pathname);
|
|
225
|
+
const walk = async (dir) => {
|
|
226
|
+
const res = await reach(url(dir), {
|
|
227
|
+
method: 'PROPFIND',
|
|
228
|
+
headers: { authorization: auth, depth: '1', 'content-type': 'application/xml' },
|
|
229
|
+
body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>',
|
|
230
|
+
});
|
|
231
|
+
if (res.status === 404)
|
|
232
|
+
return;
|
|
233
|
+
if (!res.ok)
|
|
234
|
+
throw failure('Elenco dei file', res.status, await res.text());
|
|
235
|
+
const xml = await res.text();
|
|
236
|
+
// Namespaces differ between servers (d:, D:, none): matched by local name.
|
|
237
|
+
for (const block of xml.match(/<(?:\w+:)?response[\s>][\s\S]*?<\/(?:\w+:)?response>/g) ?? []) {
|
|
238
|
+
const href = /<(?:\w+:)?href>([^<]+)</.exec(block)?.[1] ?? '';
|
|
239
|
+
const path = decodeURIComponent(new URL(href, root).pathname);
|
|
240
|
+
if (!path.startsWith(rootPath))
|
|
241
|
+
continue;
|
|
242
|
+
const key = path.slice(rootPath.length);
|
|
243
|
+
if (!key || key === dir)
|
|
244
|
+
continue;
|
|
245
|
+
// A folder: <d:collection/>, also with attributes (rclone), or an address ending in "/".
|
|
246
|
+
if (/<(?:\w+:)?collection[\s/>]/.test(block) || key.endsWith('/')) {
|
|
247
|
+
await walk(key.endsWith('/') ? key : `${key}/`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
items.push({
|
|
251
|
+
key,
|
|
252
|
+
size: Number(/<(?:\w+:)?getcontentlength>(\d+)</.exec(block)?.[1] ?? 0),
|
|
253
|
+
modified: new Date(/<(?:\w+:)?getlastmodified>([^<]+)</.exec(block)?.[1] ?? 0),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
await walk(folder);
|
|
258
|
+
return items;
|
|
259
|
+
},
|
|
260
|
+
async delete(key) {
|
|
261
|
+
await call(`Eliminazione di ${key}`, 'DELETE', key);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|