@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/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
- /** A message from a site form as plain text: one "Label: value" per field. */
38
- export declare function submissionText(page: string, data: Record<string, string>): string;
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
- /** A message from a site form as plain text: one "Label: value" per field. */
36
- export function submissionText(page, data) {
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
- return `Nuovo messaggio dal modulo del sito${page ? ` (pagina ${page})` : ''}.\n\n${lines.join('\n')}`;
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: `Il server «${m.host}» non esiste`,
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: `Il server non risponde sulla porta ${m.port}, ma risponde sulla ${open.join(' e sulla ')}`,
90
- hint: `Prova la porta ${open[0]}${open[0] === 465 ? ' con TLS' : ' con STARTTLS'}: la ${m.port} potrebbe essere bloccata dal fornitore del server.`,
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: `Il server di posta non risponde (porta ${m.port})`,
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 ? `${provider.name}: ${provider.userHint} ${provider.passHint}` : 'Controlla utente e password.',
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: `${provider?.note ?? 'Il mittente deve essere un indirizzo o un dominio verificato presso il fornitore.'}`,
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
- : { message: 'Il destinatario è stato rifiutato', hint: 'Controlla l’indirizzo del destinatario dei moduli.' };
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: `Senza SPF le email da ${domain} finiscono spesso nello spam. ${provider?.spf ? `Aggiungi nel DNS un record TXT su ${domain}: v=spf1 include:${provider.spf[0]} ~all` : 'Il fornitore della posta indica quale record TXT aggiungere nel DNS.'}`,
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: `${spf[0]}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore${provider?.spf ? ` (include:${provider.spf[0]})` : ''}.`,
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: `Il record SPF non autorizza ${provider.name}`,
154
- detail: `Aggiungi include:${provider.spf[0]} al record attuale, prima di ~all o -all: ${spf[0]}`,
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: `${provider.name} dà a ogni account nomi propri per la firma DKIM: nel suo pannello vedi se il dominio è verificato.`,
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: `Trovata con il nome ${found}._domainkey.${domain}.`,
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: `Non l’ho trovata con i nomi più comuni${provider ? ` di ${provider.name}` : ''}. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.`,
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: `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`,
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' ? `Osserva soltanto (p=none): ${dmarc}` : dmarc,
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 ? 'Chiave sbagliata, oppure il file è stato modificato' : 'Il file è incompleto o è stato modificato');
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 {
@@ -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
- : `il servizio ha risposto ${status}`;
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
- ? `Il servizio ${new URL(url).host} non si trova: controlla l’indirizzo`
35
- : `Il servizio non risponde (${cause ?? error.message})`);
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(`Il download si è interrotto (${error.message}): riprova`);
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(`Caricamento di ${key}`, 'PUT', full(key), {
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(`Caricamento di ${key}`, 'PUT', full(key), {
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(`Download di ${key}`, 'GET', full(key), { payload: EMPTY_HASH }), file);
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(`Download di ${key}`, 'GET', full(key), { payload: EMPTY_HASH })).text();
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(`Eliminazione di ${key}`, 'DELETE', full(key), { payload: EMPTY_HASH });
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(`Creazione della cartella ${dir}`, res.status, await res.text());
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(`Caricamento di ${key}`, 'PUT', key, {
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(`Caricamento di ${key}`, 'PUT', key, {
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(`Download di ${key}`, 'GET', key), file);
218
+ await saveBody(await call(t('Download di {key}', { key }), 'GET', key), file);
218
219
  },
219
220
  async getText(key) {
220
- return (await call(`Download di ${key}`, 'GET', key)).text();
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(`Eliminazione di ${key}`, 'DELETE', key);
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
- /** Raised when a remote file cannot be fetched: the message says why, in words. */
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
- }): Promise<RemoteResponse>;
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.