@materializecms/server 0.8.0 → 0.9.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 +34 -0
- package/package.json +4 -4
- package/src/admin.js +3 -2
- package/src/ai.js +17 -16
- package/src/app.d.ts +5 -0
- package/src/app.js +518 -264
- package/src/brief.js +3 -2
- package/src/en/api.d.ts +2 -0
- package/src/en/api.js +199 -0
- package/src/en/index.d.ts +5 -0
- package/src/en/index.js +7 -0
- package/src/en/services.d.ts +2 -0
- package/src/en/services.js +154 -0
- package/src/forms.d.ts +6 -3
- package/src/forms.js +22 -11
- package/src/index.d.ts +13 -0
- package/src/index.js +52 -2
- package/src/lang.d.ts +7 -0
- package/src/lang.js +17 -0
- package/src/mail.d.ts +6 -2
- package/src/mail.js +73 -40
- package/src/media.js +3 -2
- package/src/offsite.js +12 -9
- package/src/remote-storage.js +24 -23
- package/src/remote.d.ts +25 -8
- package/src/remote.js +210 -45
- package/src/status-page.js +9 -4
- package/src/t.d.ts +2 -0
- package/src/t.js +4 -0
- package/src/tools.js +28 -27
- package/src/wordpress.js +12 -11
- package/src/wpimport.js +27 -23
package/src/lang.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Lang } from '@materializecms/core/lang';
|
|
2
|
+
import type { MiddlewareHandler } from 'hono';
|
|
3
|
+
export declare const requestLang: MiddlewareHandler;
|
|
4
|
+
/** The profile's language, when the request did not say one. */
|
|
5
|
+
export declare function useProfileLang(locale: unknown): void;
|
|
6
|
+
/** Runs some work in a given language: an email to someone else, a scheduled job. */
|
|
7
|
+
export declare const withLang: <T>(lang: Lang, work: () => T) => T;
|
package/src/lang.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { isLang, setScopedLang } from '@materializecms/core/lang';
|
|
3
|
+
const store = new AsyncLocalStorage();
|
|
4
|
+
setScopedLang(() => store.getStore()?.lang);
|
|
5
|
+
export const requestLang = (c, next) => {
|
|
6
|
+
// A download link can't send headers: it says the language in the address (?lang=en).
|
|
7
|
+
const header = c.req.header('x-mz-lang') ?? c.req.query('lang');
|
|
8
|
+
return store.run(isLang(header) ? { lang: header, chosen: true } : { chosen: false }, next);
|
|
9
|
+
};
|
|
10
|
+
/** The profile's language, when the request did not say one. */
|
|
11
|
+
export function useProfileLang(locale) {
|
|
12
|
+
const s = store.getStore();
|
|
13
|
+
if (s && !s.chosen && isLang(locale))
|
|
14
|
+
s.lang = locale;
|
|
15
|
+
}
|
|
16
|
+
/** Runs some work in a given language: an email to someone else, a scheduled job. */
|
|
17
|
+
export const withLang = (lang, work) => store.run({ lang, chosen: true }, work);
|
package/src/mail.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Lang } from '@materializecms/core/lang';
|
|
1
2
|
/** Outgoing email settings, saved in the panel. The password never goes back to the browser. */
|
|
2
3
|
export interface MailSettings {
|
|
3
4
|
host: string;
|
|
@@ -34,8 +35,11 @@ export declare function sendMail(m: MailSettings, message: {
|
|
|
34
35
|
replyTo?: string;
|
|
35
36
|
headers?: Record<string, string>;
|
|
36
37
|
}): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A message from a site form as plain text: one "Label: value" per field. It goes to the site's
|
|
40
|
+
* owner, in `lang` (the current language by default).
|
|
41
|
+
*/
|
|
42
|
+
export declare function submissionText(page: string, data: Record<string, string>, lang?: Lang): string;
|
|
39
43
|
/** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
|
|
40
44
|
export declare function portOpen(host: string, port: number, timeout?: number): Promise<boolean>;
|
|
41
45
|
export interface MailProblem {
|
package/src/mail.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Resolver } from 'node:dns/promises';
|
|
2
2
|
import { connect } from 'node:net';
|
|
3
|
+
import { currentLang } from '@materializecms/core/lang';
|
|
3
4
|
import { COMMON_DKIM, providerByHost, providerById } from '@materializecms/core/mail';
|
|
4
5
|
import nodemailer from 'nodemailer';
|
|
6
|
+
import { t } from './t.js';
|
|
5
7
|
export const EMPTY_MAIL = {
|
|
6
8
|
host: '',
|
|
7
9
|
port: 587,
|
|
@@ -32,10 +34,16 @@ export async function sendMail(m, message) {
|
|
|
32
34
|
});
|
|
33
35
|
await transport.sendMail({ from: m.from, ...message });
|
|
34
36
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* A message from a site form as plain text: one "Label: value" per field. It goes to the site's
|
|
39
|
+
* owner, in `lang` (the current language by default).
|
|
40
|
+
*/
|
|
41
|
+
export function submissionText(page, data, lang = currentLang()) {
|
|
37
42
|
const lines = Object.entries(data).map(([k, v]) => `${k}:\n${v}\n`);
|
|
38
|
-
|
|
43
|
+
const intro = page
|
|
44
|
+
? t.in(lang, 'Nuovo messaggio dal modulo del sito (pagina {page}).', { page })
|
|
45
|
+
: t.in(lang, 'Nuovo messaggio dal modulo del sito.');
|
|
46
|
+
return `${intro}\n\n${lines.join('\n')}`;
|
|
39
47
|
}
|
|
40
48
|
// ---------- why sending failed, in words ----------
|
|
41
49
|
/** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
|
|
@@ -62,18 +70,18 @@ export async function explainMailError(error, m) {
|
|
|
62
70
|
const provider = providerById(m.provider) ?? providerByHost(m.host);
|
|
63
71
|
if (e.code === 'EDNS' || /ENOTFOUND|EAI_AGAIN/.test(text))
|
|
64
72
|
return {
|
|
65
|
-
message:
|
|
66
|
-
hint: 'Controlla il nome del server: un errore di battitura basta.',
|
|
73
|
+
message: t('Il server «{host}» non esiste', { host: m.host }),
|
|
74
|
+
hint: t('Controlla il nome del server: un errore di battitura basta.'),
|
|
67
75
|
};
|
|
68
76
|
if (/wrong version number|ssl3_get_record|packet length too long|unknown protocol/i.test(text))
|
|
69
77
|
return {
|
|
70
|
-
message: 'La sicurezza non corrisponde alla porta',
|
|
71
|
-
hint: 'Di solito: porta 587 con STARTTLS, porta 465 con TLS.',
|
|
78
|
+
message: t('La sicurezza non corrisponde alla porta'),
|
|
79
|
+
hint: t('Di solito: porta 587 con STARTTLS, porta 465 con TLS.'),
|
|
72
80
|
};
|
|
73
81
|
if (/certificate|self.signed|CERT_/i.test(text))
|
|
74
82
|
return {
|
|
75
|
-
message: 'Il certificato del server di posta non è valido',
|
|
76
|
-
hint: 'Controlla il nome del server: deve essere esattamente quello indicato dal fornitore.',
|
|
83
|
+
message: t('Il certificato del server di posta non è valido'),
|
|
84
|
+
hint: t('Controlla il nome del server: deve essere esattamente quello indicato dal fornitore.'),
|
|
77
85
|
};
|
|
78
86
|
if (e.code === 'ETIMEDOUT' ||
|
|
79
87
|
e.code === 'ECONNECTION' ||
|
|
@@ -86,34 +94,50 @@ export async function explainMailError(error, m) {
|
|
|
86
94
|
open.push(p);
|
|
87
95
|
if (open.length)
|
|
88
96
|
return {
|
|
89
|
-
message:
|
|
90
|
-
|
|
97
|
+
message: t('Il server non risponde sulla porta {port}, ma risponde sulla {open}', {
|
|
98
|
+
port: m.port,
|
|
99
|
+
open: open.join(t(' e sulla ')),
|
|
100
|
+
}),
|
|
101
|
+
hint: open[0] === 465
|
|
102
|
+
? t('Prova la porta {try} con TLS: la {port} potrebbe essere bloccata dal fornitore del server.', {
|
|
103
|
+
try: open[0],
|
|
104
|
+
port: m.port,
|
|
105
|
+
})
|
|
106
|
+
: t('Prova la porta {try} con STARTTLS: la {port} potrebbe essere bloccata dal fornitore del server.', {
|
|
107
|
+
try: open[0],
|
|
108
|
+
port: m.port,
|
|
109
|
+
}),
|
|
91
110
|
};
|
|
92
111
|
return {
|
|
93
|
-
message:
|
|
94
|
-
hint: 'Molti fornitori di VPS bloccano le porte della posta in uscita, soprattutto per gli account nuovi: chiedi al loro supporto di sbloccarle, oppure usa un servizio che accetta la porta 2525.',
|
|
112
|
+
message: t('Il server di posta non risponde (porta {port})', { port: m.port }),
|
|
113
|
+
hint: t('Molti fornitori di VPS bloccano le porte della posta in uscita, soprattutto per gli account nuovi: chiedi al loro supporto di sbloccarle, oppure usa un servizio che accetta la porta 2525.'),
|
|
95
114
|
};
|
|
96
115
|
}
|
|
97
116
|
if (e.code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 534)
|
|
98
117
|
return {
|
|
99
|
-
message: 'Utente o password rifiutati dal server di posta',
|
|
100
|
-
hint: provider
|
|
118
|
+
message: t('Utente o password rifiutati dal server di posta'),
|
|
119
|
+
hint: provider
|
|
120
|
+
? `${provider.name}: ${provider.userHint} ${provider.passHint}`
|
|
121
|
+
: t('Controlla utente e password.'),
|
|
101
122
|
};
|
|
102
123
|
if (e.code === 'EENVELOPE' || (e.responseCode && e.responseCode >= 550 && e.responseCode <= 554)) {
|
|
103
124
|
const sender = e.command === 'MAIL FROM' || /sender|from address|not verified|not allowed to send/i.test(text);
|
|
104
125
|
return sender
|
|
105
126
|
? {
|
|
106
|
-
message: 'Il server di posta non accetta questo mittente',
|
|
107
|
-
hint:
|
|
127
|
+
message: t('Il server di posta non accetta questo mittente'),
|
|
128
|
+
hint: provider?.note ?? t('Il mittente deve essere un indirizzo o un dominio verificato presso il fornitore.'),
|
|
108
129
|
}
|
|
109
|
-
: {
|
|
130
|
+
: {
|
|
131
|
+
message: t('Il destinatario è stato rifiutato'),
|
|
132
|
+
hint: t('Controlla l’indirizzo del destinatario dei moduli.'),
|
|
133
|
+
};
|
|
110
134
|
}
|
|
111
135
|
if (e.responseCode && e.responseCode >= 421 && e.responseCode <= 452)
|
|
112
136
|
return {
|
|
113
|
-
message: 'Il server di posta ha rimandato l’invio',
|
|
114
|
-
hint: 'Spesso è un limite di messaggi raggiunto: riprova più tardi o controlla il piano del fornitore.',
|
|
137
|
+
message: t('Il server di posta ha rimandato l’invio'),
|
|
138
|
+
hint: t('Spesso è un limite di messaggi raggiunto: riprova più tardi o controlla il piano del fornitore.'),
|
|
115
139
|
};
|
|
116
|
-
return { message: e.message || text.trim() || 'Invio non riuscito' };
|
|
140
|
+
return { message: e.message || text.trim() || t('Invio non riuscito') };
|
|
117
141
|
}
|
|
118
142
|
const txt = async (resolver, name) => (await resolver.resolveTxt(name).catch(() => [])).map((parts) => parts.join(''));
|
|
119
143
|
/**
|
|
@@ -129,39 +153,46 @@ export async function checkSenderDomain(domain, providerId) {
|
|
|
129
153
|
checks.push({
|
|
130
154
|
id: 'spf',
|
|
131
155
|
state: 'bad',
|
|
132
|
-
title: 'Manca il record SPF',
|
|
133
|
-
detail:
|
|
156
|
+
title: t('Manca il record SPF'),
|
|
157
|
+
detail: provider?.spf
|
|
158
|
+
? t('Senza SPF le email da {domain} finiscono spesso nello spam. Aggiungi nel DNS un record TXT su {domain}: v=spf1 include:{host} ~all', { domain, host: provider.spf[0] })
|
|
159
|
+
: t('Senza SPF le email da {domain} finiscono spesso nello spam. Il fornitore della posta indica quale record TXT aggiungere nel DNS.', { domain }),
|
|
134
160
|
});
|
|
135
161
|
else if (spf.length > 1)
|
|
136
162
|
checks.push({
|
|
137
163
|
id: 'spf',
|
|
138
164
|
state: 'bad',
|
|
139
|
-
title: 'Ci sono due record SPF',
|
|
140
|
-
detail: 'I server di posta li scartano entrambi: uniscili in un solo record TXT che inizia con v=spf1.',
|
|
165
|
+
title: t('Ci sono due record SPF'),
|
|
166
|
+
detail: t('I server di posta li scartano entrambi: uniscili in un solo record TXT che inizia con v=spf1.'),
|
|
141
167
|
});
|
|
142
168
|
else if (!/\b(include:|a\b|a:|mx\b|mx:|ip4:|ip6:|exists:|redirect=)/i.test(spf[0]))
|
|
143
169
|
checks.push({
|
|
144
170
|
id: 'spf',
|
|
145
171
|
state: 'bad',
|
|
146
|
-
title: 'Il record SPF non autorizza nessun server',
|
|
147
|
-
detail:
|
|
172
|
+
title: t('Il record SPF non autorizza nessun server'),
|
|
173
|
+
detail: provider?.spf
|
|
174
|
+
? t('{record}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore (include:{host}).', { record: spf[0], host: provider.spf[0] })
|
|
175
|
+
: t('{record}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore.', { record: spf[0] }),
|
|
148
176
|
});
|
|
149
177
|
else if (provider?.spf && !provider.spf.some((h) => spf[0].includes(`include:${h}`)))
|
|
150
178
|
checks.push({
|
|
151
179
|
id: 'spf',
|
|
152
180
|
state: 'warn',
|
|
153
|
-
title:
|
|
154
|
-
detail:
|
|
181
|
+
title: t('Il record SPF non autorizza {provider}', { provider: provider.name }),
|
|
182
|
+
detail: t('Aggiungi include:{host} al record attuale, prima di ~all o -all: {record}', {
|
|
183
|
+
host: provider.spf[0],
|
|
184
|
+
record: spf[0],
|
|
185
|
+
}),
|
|
155
186
|
});
|
|
156
187
|
else
|
|
157
|
-
checks.push({ id: 'spf', state: 'ok', title: 'Record SPF presente', detail: spf[0] });
|
|
188
|
+
checks.push({ id: 'spf', state: 'ok', title: t('Record SPF presente'), detail: spf[0] });
|
|
158
189
|
const selectors = provider ? provider.dkim : COMMON_DKIM;
|
|
159
190
|
if (provider && !selectors.length)
|
|
160
191
|
checks.push({
|
|
161
192
|
id: 'dkim',
|
|
162
193
|
state: 'info',
|
|
163
|
-
title: 'Firma DKIM: da controllare nel pannello del fornitore',
|
|
164
|
-
detail:
|
|
194
|
+
title: t('Firma DKIM: da controllare nel pannello del fornitore'),
|
|
195
|
+
detail: t('{provider} dà a ogni account nomi propri per la firma DKIM: nel suo pannello vedi se il dominio è verificato.', { provider: provider.name }),
|
|
165
196
|
});
|
|
166
197
|
else {
|
|
167
198
|
let found = null;
|
|
@@ -175,14 +206,16 @@ export async function checkSenderDomain(domain, providerId) {
|
|
|
175
206
|
? {
|
|
176
207
|
id: 'dkim',
|
|
177
208
|
state: 'ok',
|
|
178
|
-
title: 'Firma DKIM presente',
|
|
179
|
-
detail:
|
|
209
|
+
title: t('Firma DKIM presente'),
|
|
210
|
+
detail: t('Trovata con il nome {name}.', { name: `${found}._domainkey.${domain}` }),
|
|
180
211
|
}
|
|
181
212
|
: {
|
|
182
213
|
id: 'dkim',
|
|
183
214
|
state: 'warn',
|
|
184
|
-
title: 'Firma DKIM non trovata',
|
|
185
|
-
detail:
|
|
215
|
+
title: t('Firma DKIM non trovata'),
|
|
216
|
+
detail: provider
|
|
217
|
+
? t('Non l’ho trovata con i nomi più comuni di {provider}. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.', { provider: provider.name })
|
|
218
|
+
: t('Non l’ho trovata con i nomi più comuni. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.'),
|
|
186
219
|
});
|
|
187
220
|
}
|
|
188
221
|
const dmarc = (await txt(resolver, `_dmarc.${domain}`)).find((r) => /^v=DMARC1\b/i.test(r));
|
|
@@ -190,16 +223,16 @@ export async function checkSenderDomain(domain, providerId) {
|
|
|
190
223
|
checks.push({
|
|
191
224
|
id: 'dmarc',
|
|
192
225
|
state: 'warn',
|
|
193
|
-
title: 'Manca il record DMARC',
|
|
194
|
-
detail:
|
|
226
|
+
title: t('Manca il record DMARC'),
|
|
227
|
+
detail: t('Gmail e Yahoo lo chiedono a chi invia molte email. Un inizio sicuro, che osserva senza bloccare niente: record TXT su _dmarc.{domain} con v=DMARC1; p=none', { domain }),
|
|
195
228
|
});
|
|
196
229
|
else {
|
|
197
230
|
const policy = /\bp=(none|quarantine|reject)/i.exec(dmarc)?.[1]?.toLowerCase();
|
|
198
231
|
checks.push({
|
|
199
232
|
id: 'dmarc',
|
|
200
233
|
state: 'ok',
|
|
201
|
-
title: 'Record DMARC presente',
|
|
202
|
-
detail: policy === 'none' ?
|
|
234
|
+
title: t('Record DMARC presente'),
|
|
235
|
+
detail: policy === 'none' ? t('Osserva soltanto (p=none): {record}', { record: dmarc }) : dmarc,
|
|
203
236
|
});
|
|
204
237
|
}
|
|
205
238
|
return checks;
|
package/src/media.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
|
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { slugify } from '@materializecms/core';
|
|
7
7
|
import sharp, {} from 'sharp';
|
|
8
|
+
import { t } from './t.js';
|
|
8
9
|
/** Formats people can upload, by what the file really is (not by its name). */
|
|
9
10
|
const FORMATS = { jpeg: 'jpg', png: 'png', webp: 'webp', gif: 'gif', heif: 'avif' };
|
|
10
11
|
export const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
@@ -49,11 +50,11 @@ export function createMediaFiles(dir) {
|
|
|
49
50
|
meta = await sharp(input, { animated: true }).metadata();
|
|
50
51
|
}
|
|
51
52
|
catch {
|
|
52
|
-
throw new MediaError('Il file non è un’immagine leggibile né un modello 3D (.glb)');
|
|
53
|
+
throw new MediaError(t('Il file non è un’immagine leggibile né un modello 3D (.glb)'));
|
|
53
54
|
}
|
|
54
55
|
const format = meta.format;
|
|
55
56
|
if (!(format in FORMATS))
|
|
56
|
-
throw new MediaError('Formato non supportato: usa JPG, PNG, WebP, AVIF o GIF');
|
|
57
|
+
throw new MediaError(t('Formato non supportato: usa JPG, PNG, WebP, AVIF o GIF'));
|
|
57
58
|
const ext = FORMATS[format];
|
|
58
59
|
let output = input;
|
|
59
60
|
let width = meta.width;
|
package/src/offsite.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join, relative, resolve } from 'node:path';
|
|
5
5
|
import { createS3Storage, createWebDavStorage, } from './remote-storage.js';
|
|
6
|
+
import { t } from './t.js';
|
|
6
7
|
export const DEFAULT_OFFSITE = { kind: 'off', keep: 30 };
|
|
7
8
|
const KEY_MARK = 'mzk1-';
|
|
8
9
|
const MAGIC = Buffer.from('MZB1');
|
|
@@ -17,7 +18,7 @@ export function parseRecoveryKey(text) {
|
|
|
17
18
|
const raw = clean.startsWith(KEY_MARK) ? clean.slice(KEY_MARK.length) : clean;
|
|
18
19
|
const key = Buffer.from(raw, 'base64url');
|
|
19
20
|
if (key.length !== 32)
|
|
20
|
-
throw new Error('La chiave di ripristino non è valida: deve iniziare con mzk1-');
|
|
21
|
+
throw new Error(t('La chiave di ripristino non è valida: deve iniziare con mzk1-'));
|
|
21
22
|
return key;
|
|
22
23
|
}
|
|
23
24
|
const nonceOf = (prefix, index) => {
|
|
@@ -60,19 +61,19 @@ export async function decryptFile(source, target, key) {
|
|
|
60
61
|
const head = Buffer.alloc(12);
|
|
61
62
|
await input.read(head, 0, 12, 0);
|
|
62
63
|
if (!head.subarray(0, 4).equals(MAGIC))
|
|
63
|
-
throw new Error('Non è un file di backup cifrato da Materialize');
|
|
64
|
+
throw new Error(t('Non è un file di backup cifrato da Materialize'));
|
|
64
65
|
const prefix = head.subarray(4, 12);
|
|
65
66
|
let position = 12;
|
|
66
67
|
for (let i = 0;; i++) {
|
|
67
68
|
const length = Buffer.alloc(4);
|
|
68
69
|
if ((await input.read(length, 0, 4, position)).bytesRead < 4)
|
|
69
|
-
throw new Error('Il file è incompleto');
|
|
70
|
+
throw new Error(t('Il file è incompleto'));
|
|
70
71
|
const size = length.readUInt32BE();
|
|
71
72
|
if (size > BLOCK)
|
|
72
|
-
throw new Error('Il file è danneggiato');
|
|
73
|
+
throw new Error(t('Il file è danneggiato'));
|
|
73
74
|
const sealed = Buffer.alloc(size + 16);
|
|
74
75
|
if ((await input.read(sealed, 0, size + 16, position + 4)).bytesRead < size + 16)
|
|
75
|
-
throw new Error('Il file è incompleto');
|
|
76
|
+
throw new Error(t('Il file è incompleto'));
|
|
76
77
|
position += 4 + size + 16;
|
|
77
78
|
const end = position >= (await input.stat()).size;
|
|
78
79
|
const decipher = createDecipheriv('aes-256-gcm', key, nonceOf(prefix, i));
|
|
@@ -82,7 +83,9 @@ export async function decryptFile(source, target, key) {
|
|
|
82
83
|
await output.write(Buffer.concat([decipher.update(sealed.subarray(0, size)), decipher.final()]));
|
|
83
84
|
}
|
|
84
85
|
catch {
|
|
85
|
-
throw new Error(end
|
|
86
|
+
throw new Error(end
|
|
87
|
+
? t('Chiave sbagliata, oppure il file è stato modificato')
|
|
88
|
+
: t('Il file è incompleto o è stato modificato'));
|
|
86
89
|
}
|
|
87
90
|
if (end)
|
|
88
91
|
break;
|
|
@@ -144,7 +147,7 @@ export async function runOffsite(s, backups, mediaDir, fresh = false,
|
|
|
144
147
|
/** For tests: a storage in memory. */
|
|
145
148
|
storage = storageOf(s)) {
|
|
146
149
|
if (!storage || !s.key)
|
|
147
|
-
throw new Error('La copia fuori dal server non è configurata');
|
|
150
|
+
throw new Error(t('La copia fuori dal server non è configurata'));
|
|
148
151
|
const key = parseRecoveryKey(s.key);
|
|
149
152
|
const work = await mkdtemp(join(tmpdir(), 'materialize-offsite-'));
|
|
150
153
|
try {
|
|
@@ -201,13 +204,13 @@ storage = storageOf(s)) {
|
|
|
201
204
|
export async function testOffsite(s) {
|
|
202
205
|
const storage = storageOf(s);
|
|
203
206
|
if (!storage)
|
|
204
|
-
throw new Error('Scegli S3 o WebDAV e compila i campi');
|
|
207
|
+
throw new Error(t('Scegli S3 o WebDAV e compila i campi'));
|
|
205
208
|
const key = `.materialize-prova-${randomBytes(4).toString('hex')}.txt`;
|
|
206
209
|
const text = `Prova di Materialize, ${new Date().toISOString()}`;
|
|
207
210
|
await storage.putText(key, text);
|
|
208
211
|
try {
|
|
209
212
|
if ((await storage.getText(key)) !== text)
|
|
210
|
-
throw new Error('Il file di prova è tornato diverso');
|
|
213
|
+
throw new Error(t('Il file di prova è tornato diverso'));
|
|
211
214
|
await storage.list('');
|
|
212
215
|
}
|
|
213
216
|
finally {
|
package/src/remote-storage.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createReadStream, createWriteStream } from 'node:fs';
|
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
4
|
import { Readable } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { t } from './t.js';
|
|
6
7
|
export class RemoteError extends Error {
|
|
7
8
|
status;
|
|
8
9
|
constructor(message, status) {
|
|
@@ -13,14 +14,14 @@ export class RemoteError extends Error {
|
|
|
13
14
|
/** A human reason for an HTTP failure, the same for both kinds of storage. */
|
|
14
15
|
function failure(what, status, detail = '') {
|
|
15
16
|
const why = status === 401 || status === 403
|
|
16
|
-
? 'accesso rifiutato: controlla le credenziali e i permessi'
|
|
17
|
+
? t('accesso rifiutato: controlla le credenziali e i permessi')
|
|
17
18
|
: status === 404
|
|
18
|
-
? 'non trovato: controlla l’indirizzo, il bucket o la cartella'
|
|
19
|
+
? t('non trovato: controlla l’indirizzo, il bucket o la cartella')
|
|
19
20
|
: status === 409
|
|
20
|
-
? 'una cartella del percorso non esiste: crea almeno quella che contiene la cartella dei backup'
|
|
21
|
+
? t('una cartella del percorso non esiste: crea almeno quella che contiene la cartella dei backup')
|
|
21
22
|
: status === 507 || /QuotaExceeded|InsufficientStorage/i.test(detail)
|
|
22
|
-
? 'spazio esaurito'
|
|
23
|
-
:
|
|
23
|
+
? t('spazio esaurito')
|
|
24
|
+
: t('il servizio ha risposto {status}', { status });
|
|
24
25
|
const code = /<Code>([^<]+)<\/Code>/.exec(detail)?.[1];
|
|
25
26
|
return new RemoteError(`${what}: ${why}${code ? ` (${code})` : ''}`, status);
|
|
26
27
|
}
|
|
@@ -31,19 +32,19 @@ async function reach(url, init) {
|
|
|
31
32
|
catch (error) {
|
|
32
33
|
const cause = error.cause?.code;
|
|
33
34
|
throw new RemoteError(cause === 'ENOTFOUND'
|
|
34
|
-
?
|
|
35
|
-
:
|
|
35
|
+
? t('Il servizio {host} non si trova: controlla l’indirizzo', { host: new URL(url).host })
|
|
36
|
+
: t('Il servizio non risponde ({cause})', { cause: cause ?? error.message }));
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
const fileBody = (file) => Readable.toWeb(createReadStream(file));
|
|
39
40
|
async function saveBody(res, file) {
|
|
40
41
|
if (!res.body)
|
|
41
|
-
throw new RemoteError('Risposta vuota');
|
|
42
|
+
throw new RemoteError(t('Risposta vuota'));
|
|
42
43
|
try {
|
|
43
44
|
await pipeline(Readable.fromWeb(res.body), createWriteStream(file));
|
|
44
45
|
}
|
|
45
46
|
catch (error) {
|
|
46
|
-
throw new RemoteError(
|
|
47
|
+
throw new RemoteError(t('Il download si è interrotto ({cause}): riprova', { cause: error.message }));
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
// ---------- S3 ----------
|
|
@@ -121,24 +122,24 @@ export function createS3Storage(target, prefix = '') {
|
|
|
121
122
|
return {
|
|
122
123
|
async put(key, file) {
|
|
123
124
|
const { size } = await stat(file);
|
|
124
|
-
await call(
|
|
125
|
+
await call(t('Caricamento di {key}', { key }), 'PUT', full(key), {
|
|
125
126
|
headers: { 'content-length': String(size), 'content-type': 'application/octet-stream' },
|
|
126
127
|
body: fileBody(file),
|
|
127
128
|
});
|
|
128
129
|
},
|
|
129
130
|
async putText(key, text) {
|
|
130
131
|
const body = Buffer.from(text);
|
|
131
|
-
await call(
|
|
132
|
+
await call(t('Caricamento di {key}', { key }), 'PUT', full(key), {
|
|
132
133
|
headers: { 'content-length': String(body.length), 'content-type': 'text/plain; charset=utf-8' },
|
|
133
134
|
body,
|
|
134
135
|
payload: sha256(body),
|
|
135
136
|
});
|
|
136
137
|
},
|
|
137
138
|
async get(key, file) {
|
|
138
|
-
await saveBody(await call(
|
|
139
|
+
await saveBody(await call(t('Download di {key}', { key }), 'GET', full(key), { payload: EMPTY_HASH }), file);
|
|
139
140
|
},
|
|
140
141
|
async getText(key) {
|
|
141
|
-
return (await call(
|
|
142
|
+
return (await call(t('Download di {key}', { key }), 'GET', full(key), { payload: EMPTY_HASH })).text();
|
|
142
143
|
},
|
|
143
144
|
async list(folder) {
|
|
144
145
|
const items = [];
|
|
@@ -148,7 +149,7 @@ export function createS3Storage(target, prefix = '') {
|
|
|
148
149
|
const query = { 'list-type': '2', prefix: full(folder) };
|
|
149
150
|
if (token)
|
|
150
151
|
query['continuation-token'] = token;
|
|
151
|
-
const xml = await (await call('Elenco dei file', 'GET', '', { query, payload: EMPTY_HASH })).text();
|
|
152
|
+
const xml = await (await call(t('Elenco dei file'), 'GET', '', { query, payload: EMPTY_HASH })).text();
|
|
152
153
|
for (const block of xml.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? [])
|
|
153
154
|
items.push({
|
|
154
155
|
key: unescapeXml(xmlValue(block, 'Key')).slice(strip),
|
|
@@ -160,7 +161,7 @@ export function createS3Storage(target, prefix = '') {
|
|
|
160
161
|
return items;
|
|
161
162
|
},
|
|
162
163
|
async delete(key) {
|
|
163
|
-
await call(
|
|
164
|
+
await call(t('Eliminazione di {key}', { key }), 'DELETE', full(key), { payload: EMPTY_HASH });
|
|
164
165
|
},
|
|
165
166
|
};
|
|
166
167
|
}
|
|
@@ -181,7 +182,7 @@ export function createWebDavStorage(target) {
|
|
|
181
182
|
if (!made.has('')) {
|
|
182
183
|
const res = await reach(root, { method: 'MKCOL', headers: { authorization: auth } });
|
|
183
184
|
if (!res.ok && res.status !== 405)
|
|
184
|
-
throw failure('Creazione della cartella dei backup', res.status, await res.text());
|
|
185
|
+
throw failure(t('Creazione della cartella dei backup'), res.status, await res.text());
|
|
185
186
|
made.add('');
|
|
186
187
|
}
|
|
187
188
|
const parts = key.split('/').slice(0, -1);
|
|
@@ -192,7 +193,7 @@ export function createWebDavStorage(target) {
|
|
|
192
193
|
const res = await reach(url(dir), { method: 'MKCOL', headers: { authorization: auth } });
|
|
193
194
|
// 405: it exists already.
|
|
194
195
|
if (!res.ok && res.status !== 405)
|
|
195
|
-
throw failure(
|
|
196
|
+
throw failure(t('Creazione della cartella {dir}', { dir }), res.status, await res.text());
|
|
196
197
|
made.add(dir);
|
|
197
198
|
}
|
|
198
199
|
};
|
|
@@ -200,7 +201,7 @@ export function createWebDavStorage(target) {
|
|
|
200
201
|
async put(key, file) {
|
|
201
202
|
await folders(key);
|
|
202
203
|
const { size } = await stat(file);
|
|
203
|
-
await call(
|
|
204
|
+
await call(t('Caricamento di {key}', { key }), 'PUT', key, {
|
|
204
205
|
headers: { 'content-length': String(size), 'content-type': 'application/octet-stream' },
|
|
205
206
|
body: fileBody(file),
|
|
206
207
|
duplex: 'half',
|
|
@@ -208,16 +209,16 @@ export function createWebDavStorage(target) {
|
|
|
208
209
|
},
|
|
209
210
|
async putText(key, text) {
|
|
210
211
|
await folders(key);
|
|
211
|
-
await call(
|
|
212
|
+
await call(t('Caricamento di {key}', { key }), 'PUT', key, {
|
|
212
213
|
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
213
214
|
body: text,
|
|
214
215
|
});
|
|
215
216
|
},
|
|
216
217
|
async get(key, file) {
|
|
217
|
-
await saveBody(await call(
|
|
218
|
+
await saveBody(await call(t('Download di {key}', { key }), 'GET', key), file);
|
|
218
219
|
},
|
|
219
220
|
async getText(key) {
|
|
220
|
-
return (await call(
|
|
221
|
+
return (await call(t('Download di {key}', { key }), 'GET', key)).text();
|
|
221
222
|
},
|
|
222
223
|
async list(folder) {
|
|
223
224
|
const items = [];
|
|
@@ -231,7 +232,7 @@ export function createWebDavStorage(target) {
|
|
|
231
232
|
if (res.status === 404)
|
|
232
233
|
return;
|
|
233
234
|
if (!res.ok)
|
|
234
|
-
throw failure('Elenco dei file', res.status, await res.text());
|
|
235
|
+
throw failure(t('Elenco dei file'), res.status, await res.text());
|
|
235
236
|
const xml = await res.text();
|
|
236
237
|
// Namespaces differ between servers (d:, D:, none): matched by local name.
|
|
237
238
|
for (const block of xml.match(/<(?:\w+:)?response[\s>][\s\S]*?<\/(?:\w+:)?response>/g) ?? []) {
|
|
@@ -258,7 +259,7 @@ export function createWebDavStorage(target) {
|
|
|
258
259
|
return items;
|
|
259
260
|
},
|
|
260
261
|
async delete(key) {
|
|
261
|
-
await call(
|
|
262
|
+
await call(t('Eliminazione di {key}', { key }), 'DELETE', key);
|
|
262
263
|
},
|
|
263
264
|
};
|
|
264
265
|
}
|
package/src/remote.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Raised when a remote file cannot be fetched: the message says why, in words (in the reader's
|
|
3
|
+
* language); `reason` says it to code.
|
|
4
|
+
*/
|
|
2
5
|
export declare class RemoteError extends Error {
|
|
6
|
+
readonly reason?: 'too_large' | 'private';
|
|
7
|
+
constructor(message: string, reason?: 'too_large' | 'private');
|
|
3
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Loopback, private, link-local and other addresses a public file never lives at. IPv6 is read
|
|
11
|
+
* group by group, so the same IPv4 address cannot slip through written another way (mapped as
|
|
12
|
+
* ::ffff:7f00:1, compatible, NAT64, 6to4). Anything that is not an address counts as private.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isPrivate(address: string): boolean;
|
|
4
15
|
export interface RemoteResponse {
|
|
5
16
|
status: number;
|
|
6
17
|
headers: Headers;
|
|
@@ -10,17 +21,23 @@ export interface RemoteResponse {
|
|
|
10
21
|
name: string;
|
|
11
22
|
url: string;
|
|
12
23
|
}
|
|
13
|
-
|
|
14
|
-
* Fetches from the web: http(s) only, redirects checked one by one, at most `max` bytes. Addresses
|
|
15
|
-
* on this machine or its network are refused, unless `allowPrivate` (an administrator importing
|
|
16
|
-
* from a WordPress on their own network). Headers are sent to the first host only.
|
|
17
|
-
*/
|
|
18
|
-
export declare function remoteFetch(url: string, options: {
|
|
24
|
+
export interface RemoteOptions {
|
|
19
25
|
max: number;
|
|
20
26
|
headers?: Record<string, string>;
|
|
21
27
|
allowPrivate?: boolean;
|
|
22
28
|
timeout?: number;
|
|
23
|
-
|
|
29
|
+
method?: 'GET' | 'POST';
|
|
30
|
+
body?: string;
|
|
31
|
+
/** Redirects followed before giving up; 0 refuses any (a webhook must answer where it was set). */
|
|
32
|
+
maxRedirects?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetches from the web: http(s) only, redirects checked one by one, at most `max` bytes. Addresses
|
|
36
|
+
* on this machine or its network are refused, unless `allowPrivate` (an administrator importing
|
|
37
|
+
* from a WordPress on their own network), and checked when connecting, not before. Headers are
|
|
38
|
+
* sent to the first host only.
|
|
39
|
+
*/
|
|
40
|
+
export declare function remoteFetch(url: string, options: RemoteOptions): Promise<RemoteResponse>;
|
|
24
41
|
/**
|
|
25
42
|
* Downloads a public file (an image an AI found on the web): http(s) only, never to an address on
|
|
26
43
|
* this machine or its network, redirects checked one by one, at most `max` bytes.
|