@materializecms/server 0.1.0 → 0.3.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 CHANGED
@@ -4,6 +4,22 @@ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Pros
4
4
 
5
5
  ## Prossima versione
6
6
 
7
+ ## 0.3.0 — 2026-11-01
8
+
9
+ - **Backup fuori dal server**, ogni notte e cifrati, su uno spazio S3 compatibile (Cloudflare R2, Backblaze B2, Hetzner, Amazon S3…) o in una cartella WebDAV (Nextcloud, NAS Synology e QNAP…). Si attiva in Impostazioni → Backup; `materialize decrypt` rende di nuovo leggibili i file scaricati.
10
+ - **Email più facile da configurare**: si sceglie il fornitore (Brevo, Postmark, Mailgun, Amazon SES, Gmail…) e il server si compila da solo; se la prova non riesce, il pannello spiega perché (porta bloccata, password rifiutata, mittente non verificato…); **Controlla il dominio** verifica SPF, DKIM e DMARC.
11
+ - **Pannello più chiaro**: Impostazioni → Protezione divisa in Sicurezza, Backup e Registro attività; barra dell'editor a gruppi, con gli elementi meno usati in **⋯**; pannello sinistro dell'editor allargabile; menu a tendina, intestazioni e testi d'aiuto uniformi e più leggibili; pagina dedicata per gli indirizzi del pannello che non esistono; in Persone, chi entra con la sola password si riconosce a colpo d'occhio; accesso e installazione con l'aspetto del pannello.
12
+ - **Chi ha fatto ogni versione**: la cronologia mostra il nome dell'autore accanto alla data.
13
+ - **Informazioni su Materialize** in Impostazioni: versione, autore, licenza, novità, guida e licenze di terzi.
14
+
15
+ ## 0.2.0 — 2026-11-01
16
+
17
+ - **Password dimenticata?** nella pagina di accesso: un link per email, valido un'ora. Senza email configurata, il comando `materialize reset-password` stampa il link sul server, anche per l'unico amministratore.
18
+ - **Cronologia per le pagine sulla tela**: ogni versione si guarda su desktop, tablet e mobile prima di ripristinarla.
19
+ - **Esporta la pagina in PDF o PNG** dall'editor, per mandarla al cliente: desktop, tablet e mobile, ognuno come un'unica immagine lunga.
20
+ - **Guida per chi usa Materialize**, in `docs/guida`: installazione, editor, contenuti, lingue, SEO, persone, aggiornamenti, backup e problemi comuni.
21
+ - **Sicurezza**: i link degli inviti e delle nuove password puntano sempre all'indirizzo pubblico del sito, mai a quello dichiarato da chi fa la richiesta. Consigliato aggiornare.
22
+
7
23
  ## 0.1.0 — 2026-10-31
8
24
 
9
25
  La prima versione pubblica.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Cristiano Casazza
3
+ Copyright (c) 2026 Cristiano Casazza Consulting
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@anthropic-ai/sdk": "1.3.0",
22
22
  "@hono/node-server": "2.1.5",
23
- "@materializecms/admin": "0.1.0",
24
- "@materializecms/core": "0.1.0",
25
- "@materializecms/db": "0.1.0",
23
+ "@materializecms/admin": "0.3.0",
24
+ "@materializecms/core": "0.3.0",
25
+ "@materializecms/db": "0.3.0",
26
26
  "@modelcontextprotocol/sdk": "1.32.2",
27
27
  "hono": "4.13.14",
28
28
  "htmlparser2": "12.0.0",
package/src/app.js CHANGED
@@ -8,6 +8,7 @@ import { Readable } from 'node:stream';
8
8
  import { getConnInfo } from '@hono/node-server/conninfo';
9
9
  import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
10
10
  import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
11
+ import { senderDomain } from '@materializecms/core/mail';
11
12
  import { normalizeTheme, sameDesign } from '@materializecms/core/page';
12
13
  import { addMove, normalizePath } from '@materializecms/core/seo';
13
14
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
@@ -27,9 +28,10 @@ import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts
27
28
  import { folderEntries, tarGz } from './archive.js';
28
29
  import { createBackups, DEFAULT_BACKUPS } from './backups.js';
29
30
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
30
- import { isConfigured, publicMail, sendMail, submissionText } from './mail.js';
31
+ import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
31
32
  import { createMcpServer } from './mcp.js';
32
33
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
34
+ import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './offsite.js';
33
35
  import { hashPassword, verifyPassword } from './password.js';
34
36
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
35
37
  import { statusPage } from './status-page.js';
@@ -52,6 +54,11 @@ const CHALLENGE_TTL = 5 * 60 * 1000;
52
54
  const dummyHash = hashPassword(randomBytes(16).toString('hex'));
53
55
  const INVITE_TTL = 7 * 24 * 60 * 60 * 1000;
54
56
  const RESET_TTL = 24 * 60 * 60 * 1000;
57
+ /** A link asked for from the sign-in page: shorter, since nobody vouched for the request. */
58
+ const FORGOT_TTL = 60 * 60 * 1000;
59
+ /** Links asked for per email, and per address, before a pause: no one is flooded with emails. */
60
+ const FORGOT_THRESHOLD = 3;
61
+ const FORGOT_IP_THRESHOLD = 10;
55
62
  /** Where people open their one-time link: the admin's welcome screen. */
56
63
  const linkPath = (token) => `/admin/_invite/${token}`;
57
64
  /** The custom code blocks in a document: it runs on the public site, so only admins may change it. */
@@ -198,8 +205,61 @@ export function createApp(base) {
198
205
  });
199
206
  };
200
207
  const backupFailed = (error) => console.error('Materialize: backup not made', error);
201
- setTimeout(() => void autoBackup().catch(backupFailed), 60_000).unref?.();
202
- setInterval(() => void autoBackup().catch(backupFailed), 60 * 60_000).unref?.();
208
+ // ---------- the copy outside the server (S3, WebDAV) ----------
209
+ const offsiteSettings = async () => ({
210
+ ...DEFAULT_OFFSITE,
211
+ ...(await settings.get('offsite')),
212
+ });
213
+ let offsiteRunning = false;
214
+ /** One run; the outcome is kept, and administrators hear by email the first time it fails. */
215
+ const offsiteRun = async (fresh) => {
216
+ if (offsiteRunning)
217
+ throw new ApiError(409, 'offsite_running', 'A copy is already being made');
218
+ offsiteRunning = true;
219
+ const before = await settings.get('offsite-status');
220
+ let status;
221
+ try {
222
+ status = await runOffsite(await offsiteSettings(), backups, ctx.config().media.dir, fresh);
223
+ }
224
+ catch (error) {
225
+ status = { at: new Date().toISOString(), ok: false, message: error.message };
226
+ if (before?.ok !== false)
227
+ for (const admin of (await auth.listUsers()).filter((u) => u.role === 'admin'))
228
+ await mailNotice(admin.email, 'Backup fuori dal server non riuscito', [
229
+ `La copia dei backup di ${ctx.config().site.name} fuori dal server non è riuscita: ${status.message}`,
230
+ 'Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.',
231
+ ]);
232
+ }
233
+ finally {
234
+ offsiteRunning = false;
235
+ }
236
+ await settings.set('offsite-status', status);
237
+ await activityLog.record({
238
+ userId: null,
239
+ userName: '',
240
+ action: status.ok ? 'backup.offsite' : 'backup.offsite_failed',
241
+ target: status.database ?? '',
242
+ detail: status.ok ? { media: status.media, fonts: status.fonts } : { message: status.message },
243
+ ip: null,
244
+ });
245
+ return status;
246
+ };
247
+ /** After the daily copy: once a day, unless the last run failed (then every hour until it works). */
248
+ const autoOffsite = async () => {
249
+ const s = await offsiteSettings();
250
+ if (s.kind === 'off' || !s.key || offsiteRunning)
251
+ return;
252
+ const last = await settings.get('offsite-status');
253
+ if (last?.ok && Date.now() - new Date(last.at).getTime() < 23 * 60 * 60_000)
254
+ return;
255
+ await offsiteRun(false);
256
+ };
257
+ const nightly = async () => {
258
+ await autoBackup().catch(backupFailed);
259
+ await autoOffsite().catch((error) => console.error('Materialize: offsite copy not made', error));
260
+ };
261
+ setTimeout(() => void nightly(), 60_000).unref?.();
262
+ setInterval(() => void nightly(), 60 * 60_000).unref?.();
203
263
  const updateSettings = async () => ({
204
264
  ...DEFAULT_UPDATES,
205
265
  ...(await settings.get('updates')),
@@ -643,6 +703,40 @@ export function createApp(base) {
643
703
  const { passwordHash: _, ...user } = found;
644
704
  return signIn(c, user);
645
705
  });
706
+ /** For the sign-in page: whether "Password dimenticata?" can send an email, or someone has to help. */
707
+ app.get('/api/auth/recovery', async (c) => c.json({ mail: isConfigured(await settings.get('mail')) }));
708
+ /**
709
+ * A forgotten password: a one-time link by email, valid an hour. The answer is the same whether
710
+ * the email has an account or not, so nobody can learn who is registered; requests per email and
711
+ * per address are limited, so nobody can flood an inbox.
712
+ */
713
+ app.post('/api/auth/forgot', async (c) => {
714
+ const input = await body(c, z.object({ email: z.email() }));
715
+ const email = input.email.trim().toLowerCase();
716
+ const ip = clientIp(c);
717
+ await checkLocks(`forgot:${email}`, `forgot-ip:${ip}`);
718
+ await auth.recordFailure(`forgot:${email}`, FORGOT_THRESHOLD);
719
+ await auth.recordFailure(`forgot-ip:${ip}`, FORGOT_IP_THRESHOLD);
720
+ const mail = isConfigured(await settings.get('mail'));
721
+ const found = mail ? await auth.findForLogin(email) : null;
722
+ if (found) {
723
+ const { token } = await auth.createInvite({
724
+ kind: 'reset',
725
+ email: found.email,
726
+ name: found.name,
727
+ role: found.role,
728
+ userId: found.id,
729
+ ttlMs: FORGOT_TTL,
730
+ });
731
+ await mailLink(c, found.email, 'Nuova password', [
732
+ `Qualcuno ha chiesto una nuova password per il tuo account su ${ctx.config().site.name}.`,
733
+ 'Se non sei stato tu, ignora questa email: la password resta quella di prima.',
734
+ 'Per sceglierne una nuova, apri questo link entro un’ora:',
735
+ ], linkPath(token));
736
+ await record(c, 'password.forgot', found.email, null, { id: found.id, name: found.name });
737
+ }
738
+ return c.json({ ok: true, mail });
739
+ });
646
740
  /** The second step: a code from the app, or a recovery code. */
647
741
  app.post('/api/auth/login/verify', async (c) => {
648
742
  const input = await body(c, z.object({ ticket: z.string().min(1).max(100), code: z.string().trim().min(6).max(20) }));
@@ -770,7 +864,7 @@ export function createApp(base) {
770
864
  app.get('/api/security', requireUser, requireAdmin, async (c) => c.json({ requireTwoFactor: await twoFactorRequired() }));
771
865
  /** Groups of the activity log, by the start of their action codes. */
772
866
  const ACTIVITY_GROUPS = {
773
- access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.'],
867
+ access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
774
868
  people: ['user.'],
775
869
  content: ['document.', 'media.', 'message.'],
776
870
  settings: ['settings.', 'backup.', 'update.'],
@@ -941,7 +1035,35 @@ export function createApp(base) {
941
1035
  id: 'backups',
942
1036
  status: 'ok',
943
1037
  title: 'Backup del database ogni giorno',
944
- detail: `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round(age / 3_600_000), 'hour')}, immagini comprese; ne restano ${b.keep}. Per una copia fuori dal server, scarica l’archivio completo.`,
1038
+ detail: `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round(age / 3_600_000), 'hour')}, immagini comprese; ne restano ${b.keep}.`,
1039
+ });
1040
+ }
1041
+ {
1042
+ const o = await offsiteSettings();
1043
+ const status = await settings.get('offsite-status');
1044
+ checks.push(o.kind === 'off' || !o.key
1045
+ ? {
1046
+ id: 'offsite',
1047
+ status: 'warn',
1048
+ title: 'I backup restano sul server',
1049
+ detail: 'Se il server si rompe, si perdono anche le copie. Attiva la copia fuori dal server (S3 o WebDAV), oppure scarica ogni tanto l’archivio completo.',
1050
+ action: 'backups',
1051
+ }
1052
+ : status && !status.ok
1053
+ ? {
1054
+ id: 'offsite',
1055
+ status: 'bad',
1056
+ title: 'La copia fuori dal server non riesce',
1057
+ detail: status.message ?? 'Controlla il collegamento.',
1058
+ action: 'backups',
1059
+ }
1060
+ : {
1061
+ id: 'offsite',
1062
+ status: 'ok',
1063
+ title: 'Copia cifrata fuori dal server',
1064
+ detail: status
1065
+ ? `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round((Date.now() - new Date(status.at).getTime()) / 3_600_000), 'hour')}, su ${o.kind === 's3' ? 'S3' : 'WebDAV'}.`
1066
+ : 'La prima copia parte entro un’ora.',
945
1067
  });
946
1068
  }
947
1069
  const tokens = await activityLog.tokens();
@@ -1008,6 +1130,100 @@ export function createApp(base) {
1008
1130
  await record(c, 'settings.backups', '', input);
1009
1131
  return c.json(await backupState());
1010
1132
  });
1133
+ /** What the panel may see: no passwords, no secret key, no recovery key. */
1134
+ const offsiteState = async () => {
1135
+ const s = await offsiteSettings();
1136
+ return {
1137
+ kind: s.kind,
1138
+ keep: s.keep,
1139
+ hasKey: !!s.key,
1140
+ s3: s.s3 ? { ...s.s3, secretAccessKey: '', hasSecret: !!s.s3.secretAccessKey } : undefined,
1141
+ webdav: s.webdav ? { ...s.webdav, password: '', hasSecret: !!s.webdav.password } : undefined,
1142
+ status: (await settings.get('offsite-status')) ?? null,
1143
+ running: offsiteRunning,
1144
+ };
1145
+ };
1146
+ const s3Input = z.object({
1147
+ endpoint: z.url({ protocol: /^https?$/ }).max(300),
1148
+ region: z.string().trim().max(60),
1149
+ bucket: z.string().trim().min(1).max(200),
1150
+ prefix: z.string().trim().max(200).optional(),
1151
+ accessKeyId: z.string().trim().min(1).max(200),
1152
+ // Empty: keep the saved one.
1153
+ secretAccessKey: z.string().max(500),
1154
+ });
1155
+ const webdavInput = z.object({
1156
+ url: z.url({ protocol: /^https?$/ }).max(500),
1157
+ user: z.string().trim().max(200),
1158
+ password: z.string().max(500),
1159
+ });
1160
+ const offsiteInput = z.object({
1161
+ kind: z.enum(['off', 's3', 'webdav']),
1162
+ keep: z.number().int().min(1).max(365),
1163
+ s3: s3Input.optional(),
1164
+ webdav: webdavInput.optional(),
1165
+ });
1166
+ /** The settings as sent, with the saved secrets where the panel left them empty. */
1167
+ const withSecrets = (input, saved) => {
1168
+ if (input.kind === 's3' && !input.s3)
1169
+ throw new ApiError(400, 'missing_s3', 'S3 settings are missing');
1170
+ if (input.kind === 'webdav' && !input.webdav)
1171
+ throw new ApiError(400, 'missing_webdav', 'WebDAV settings are missing');
1172
+ return {
1173
+ kind: input.kind,
1174
+ keep: input.keep,
1175
+ key: saved.key,
1176
+ s3: input.s3
1177
+ ? { ...input.s3, secretAccessKey: input.s3.secretAccessKey || saved.s3?.secretAccessKey || '' }
1178
+ : saved.s3,
1179
+ webdav: input.webdav
1180
+ ? { ...input.webdav, password: input.webdav.password || saved.webdav?.password || '' }
1181
+ : saved.webdav,
1182
+ };
1183
+ };
1184
+ app.get('/api/backups/offsite', requireUser, requireSession, requireAdmin, async (c) => c.json(await offsiteState()));
1185
+ /** Saves where the copy goes; the first time, makes the recovery key and returns it, once. */
1186
+ app.put('/api/backups/offsite', requireUser, requireSession, requireAdmin, async (c) => {
1187
+ const next = withSecrets(await body(c, offsiteInput), await offsiteSettings());
1188
+ let shown;
1189
+ if (next.kind !== 'off' && !next.key)
1190
+ next.key = shown = newRecoveryKey();
1191
+ await settings.set('offsite', next, c.get('user').id);
1192
+ await record(c, 'settings.offsite', next.kind);
1193
+ return c.json({ ...(await offsiteState()), recoveryKey: shown });
1194
+ });
1195
+ app.post('/api/backups/offsite/test', requireUser, requireSession, requireAdmin, async (c) => {
1196
+ try {
1197
+ await testOffsite(withSecrets(await body(c, offsiteInput), await offsiteSettings()));
1198
+ return c.json({ ok: true });
1199
+ }
1200
+ catch (error) {
1201
+ if (error instanceof ApiError)
1202
+ throw error;
1203
+ return c.json({ ok: false, message: error.message });
1204
+ }
1205
+ });
1206
+ /** "Esegui ora": a fresh copy of the database and whatever is missing, in the background. */
1207
+ app.post('/api/backups/offsite/run', requireUser, requireSession, requireAdmin, async (c) => {
1208
+ const s = await offsiteSettings();
1209
+ if (s.kind === 'off' || !s.key)
1210
+ throw new ApiError(409, 'offsite_off', 'The copy outside the server is off');
1211
+ if (offsiteRunning)
1212
+ throw new ApiError(409, 'offsite_running', 'A copy is already being made');
1213
+ void offsiteRun(true).catch((error) => console.error('Materialize: offsite copy not made', error));
1214
+ await record(c, 'backup.offsite_started');
1215
+ return c.json(await offsiteState(), 202);
1216
+ });
1217
+ /** The recovery key again, for an administrator who confirms their password. */
1218
+ app.post('/api/backups/offsite/key', requireUser, requireSession, requireAdmin, async (c) => {
1219
+ const input = await body(c, z.object({ password: z.string().min(1).max(500) }));
1220
+ await checkPassword(c.get('user').id, input.password);
1221
+ const s = await offsiteSettings();
1222
+ if (!s.key)
1223
+ throw new ApiError(404, 'no_key', 'There is no recovery key yet');
1224
+ await record(c, 'backup.key_shown');
1225
+ return c.json({ recoveryKey: s.key });
1226
+ });
1011
1227
  app.post('/api/backups', requireUser, requireSession, requireAdmin, async (c) => {
1012
1228
  if (!backups.available())
1013
1229
  throw new ApiError(409, 'backups_unavailable', 'The database is hosted: its provider keeps the backups');
@@ -1448,12 +1664,26 @@ export function createApp(base) {
1448
1664
  return false;
1449
1665
  }
1450
1666
  };
1667
+ /**
1668
+ * The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
1669
+ * is known. The request's Host header is used only without it, as in local development: anyone
1670
+ * can send a request with a false Host, and a link to their server would carry the token there.
1671
+ */
1672
+ const publicOrigin = (c) => {
1673
+ const site = ctx.config().site.url;
1674
+ try {
1675
+ if (site)
1676
+ return new URL(site).origin;
1677
+ }
1678
+ catch { }
1679
+ return new URL(c.req.url).origin;
1680
+ };
1451
1681
  const mailLink = async (c, to, subject, lines, path) => {
1452
1682
  const mail = await settings.get('mail');
1453
1683
  if (!isConfigured(mail))
1454
1684
  return false;
1455
1685
  try {
1456
- const url = new URL(path, new URL(c.req.url).origin).toString();
1686
+ const url = new URL(path, publicOrigin(c)).toString();
1457
1687
  await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
1458
1688
  return true;
1459
1689
  }
@@ -1474,6 +1704,7 @@ export function createApp(base) {
1474
1704
  pass: z.string().max(500).nullable().optional(),
1475
1705
  from: z.string().max(200),
1476
1706
  to: z.string().max(200),
1707
+ provider: z.string().max(40).optional(),
1477
1708
  }),
1478
1709
  }));
1479
1710
  const before = await settings.get('mail');
@@ -1496,10 +1727,19 @@ export function createApp(base) {
1496
1727
  });
1497
1728
  }
1498
1729
  catch (error) {
1499
- throw new ApiError(422, 'mail_failed', error instanceof Error ? error.message : 'Sending failed');
1730
+ // What went wrong and what to do, in words: a blocked port, a wrong password, an unverified sender…
1731
+ return c.json({ ok: false, to, ...(await explainMailError(error, mail)) });
1500
1732
  }
1501
1733
  return c.json({ ok: true, to });
1502
1734
  });
1735
+ /** The sender's domain as mail servers see it: SPF, DKIM, DMARC. */
1736
+ app.post('/api/mail/domain', requireUser, requireSession, requireAdmin, async (c) => {
1737
+ const input = await body(c, z.object({ from: z.string().max(200), provider: z.string().max(40).optional() }));
1738
+ const domain = senderDomain(input.from);
1739
+ if (!domain)
1740
+ throw new ApiError(422, 'no_domain', 'Write the sender first, for example sito@example.com');
1741
+ return c.json({ domain, checks: await checkSenderDomain(domain, input.provider) });
1742
+ });
1503
1743
  // A form on the site: the message is kept, and sent by email when email is set up.
1504
1744
  // ---------- AI: settings, text actions and the editor's assistant ----------
1505
1745
  const aiSettings = async () => ({ ...EMPTY_AI, ...(await settings.get('ai')) });
@@ -1991,7 +2231,13 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
1991
2231
  });
1992
2232
  app.get('/api/documents/:id/revisions', requireUser, async (c) => {
1993
2233
  const existing = await loadDocument(c.req.param('id'));
1994
- return c.json({ items: await content.revisions(existing.id) });
2234
+ // Who made each version, by name; null for a person removed since (and for versions with no author).
2235
+ const names = new Map((await auth.listUsers()).map((u) => [u.id, u.name]));
2236
+ const items = (await content.revisions(existing.id)).map((r) => ({
2237
+ ...r,
2238
+ authorName: r.authorId ? (names.get(r.authorId) ?? null) : null,
2239
+ }));
2240
+ return c.json({ items });
1995
2241
  });
1996
2242
  app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
1997
2243
  const existing = await loadDocument(c.req.param('id'));
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { restoreMedia } from './backups.ts';
9
9
  export { createFonts, type FontInfo, type Fonts } from './fonts.ts';
10
10
  export { createMcpServer, registerTools } from './mcp.ts';
11
11
  export { createMediaFiles, type MediaFiles } from './media.ts';
12
+ export { decryptTree, type OffsiteSettings, parseRecoveryKey } from './offsite.ts';
12
13
  export { hashPassword, verifyPassword } from './password.ts';
13
14
  export { apiCaller, siteTools } from './tools.ts';
14
15
  /** Serves the public site: every request outside /api, /admin and /media goes here. */
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ export { restoreMedia } from './backups.js';
10
10
  export { createFonts } from './fonts.js';
11
11
  export { createMcpServer, registerTools } from './mcp.js';
12
12
  export { createMediaFiles } from './media.js';
13
+ export { decryptTree, parseRecoveryKey } from './offsite.js';
13
14
  export { hashPassword, verifyPassword } from './password.js';
14
15
  export { apiCaller, siteTools } from './tools.js';
15
16
  const OWN = /^\/(api|admin|media|fonts|mcp)(\/|\?|$)/;
package/src/mail.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface MailSettings {
10
10
  from: string;
11
11
  /** Where messages from the site's forms arrive. */
12
12
  to: string;
13
+ /** The provider chosen in the panel (core/mail.ts), or empty for "another one". */
14
+ provider?: string;
13
15
  }
14
16
  export declare const EMPTY_MAIL: MailSettings;
15
17
  export declare function isConfigured(m: MailSettings | null): m is MailSettings;
@@ -21,6 +23,7 @@ export declare function publicMail(m: MailSettings | null): {
21
23
  user: string;
22
24
  from: string;
23
25
  to: string;
26
+ provider?: string;
24
27
  hasPassword: boolean;
25
28
  };
26
29
  export declare function sendMail(m: MailSettings, message: {
@@ -31,3 +34,27 @@ export declare function sendMail(m: MailSettings, message: {
31
34
  }): Promise<void>;
32
35
  /** A message from a site form as plain text: one "Label: value" per field. */
33
36
  export declare function submissionText(page: string, data: Record<string, string>): string;
37
+ /** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
38
+ export declare function portOpen(host: string, port: number, timeout?: number): Promise<boolean>;
39
+ export interface MailProblem {
40
+ message: string;
41
+ /** What to do about it. */
42
+ hint?: string;
43
+ }
44
+ /**
45
+ * A sending error from nodemailer, explained: wrong server name, a port the hosting blocks (then
46
+ * the ports that do answer), security that does not match the port, credentials refused, a sender
47
+ * the provider has not verified, a limit reached.
48
+ */
49
+ export declare function explainMailError(error: unknown, m: MailSettings): Promise<MailProblem>;
50
+ export interface DomainCheck {
51
+ id: 'spf' | 'dkim' | 'dmarc';
52
+ state: 'ok' | 'warn' | 'bad' | 'info';
53
+ title: string;
54
+ detail: string;
55
+ }
56
+ /**
57
+ * What mail servers check before trusting a message from this domain. Without SPF and DKIM the
58
+ * messages tend to end in spam; Gmail and Yahoo also ask for DMARC from whoever sends a lot.
59
+ */
60
+ export declare function checkSenderDomain(domain: string, providerId?: string): Promise<DomainCheck[]>;
package/src/mail.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { Resolver } from 'node:dns/promises';
2
+ import { connect } from 'node:net';
3
+ import { COMMON_DKIM, providerByHost, providerById } from '@materializecms/core/mail';
1
4
  import nodemailer from 'nodemailer';
2
5
  export const EMPTY_MAIL = {
3
6
  host: '',
@@ -34,3 +37,170 @@ export function submissionText(page, data) {
34
37
  const lines = Object.entries(data).map(([k, v]) => `${k}:\n${v}\n`);
35
38
  return `Nuovo messaggio dal modulo del sito${page ? ` (pagina ${page})` : ''}.\n\n${lines.join('\n')}`;
36
39
  }
40
+ // ---------- why sending failed, in words ----------
41
+ /** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
42
+ export function portOpen(host, port, timeout = 4000) {
43
+ return new Promise((resolve) => {
44
+ const socket = connect({ host, port, timeout });
45
+ const done = (ok) => {
46
+ socket.destroy();
47
+ resolve(ok);
48
+ };
49
+ socket.once('connect', () => done(true));
50
+ socket.once('timeout', () => done(false));
51
+ socket.once('error', () => done(false));
52
+ });
53
+ }
54
+ /**
55
+ * A sending error from nodemailer, explained: wrong server name, a port the hosting blocks (then
56
+ * the ports that do answer), security that does not match the port, credentials refused, a sender
57
+ * the provider has not verified, a limit reached.
58
+ */
59
+ export async function explainMailError(error, m) {
60
+ const e = error;
61
+ const text = [e.message, e.response, ...(e.errors ?? []).flatMap((x) => [x.code, x.message])].join(' ');
62
+ const provider = providerById(m.provider) ?? providerByHost(m.host);
63
+ if (e.code === 'EDNS' || /ENOTFOUND|EAI_AGAIN/.test(text))
64
+ return {
65
+ message: `Il server «${m.host}» non esiste`,
66
+ hint: 'Controlla il nome del server: un errore di battitura basta.',
67
+ };
68
+ if (/wrong version number|ssl3_get_record|packet length too long|unknown protocol/i.test(text))
69
+ return {
70
+ message: 'La sicurezza non corrisponde alla porta',
71
+ hint: 'Di solito: porta 587 con STARTTLS, porta 465 con TLS.',
72
+ };
73
+ if (/certificate|self.signed|CERT_/i.test(text))
74
+ 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.',
77
+ };
78
+ if (e.code === 'ETIMEDOUT' ||
79
+ e.code === 'ECONNECTION' ||
80
+ (e.code === 'ESOCKET' && e.command === 'CONN') ||
81
+ /ECONNREFUSED|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|ENETUNREACH|Greeting never received/i.test(text)) {
82
+ const others = [587, 465, 2525].filter((p) => p !== m.port);
83
+ const open = [];
84
+ for (const p of others)
85
+ if (await portOpen(m.host, p))
86
+ open.push(p);
87
+ if (open.length)
88
+ 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.`,
91
+ };
92
+ 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.',
95
+ };
96
+ }
97
+ if (e.code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 534)
98
+ return {
99
+ message: 'Utente o password rifiutati dal server di posta',
100
+ hint: provider ? `${provider.name}: ${provider.userHint} ${provider.passHint}` : 'Controlla utente e password.',
101
+ };
102
+ if (e.code === 'EENVELOPE' || (e.responseCode && e.responseCode >= 550 && e.responseCode <= 554)) {
103
+ const sender = e.command === 'MAIL FROM' || /sender|from address|not verified|not allowed to send/i.test(text);
104
+ return sender
105
+ ? {
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.'}`,
108
+ }
109
+ : { message: 'Il destinatario è stato rifiutato', hint: 'Controlla l’indirizzo del destinatario dei moduli.' };
110
+ }
111
+ if (e.responseCode && e.responseCode >= 421 && e.responseCode <= 452)
112
+ 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.',
115
+ };
116
+ return { message: e.message || text.trim() || 'Invio non riuscito' };
117
+ }
118
+ const txt = async (resolver, name) => (await resolver.resolveTxt(name).catch(() => [])).map((parts) => parts.join(''));
119
+ /**
120
+ * What mail servers check before trusting a message from this domain. Without SPF and DKIM the
121
+ * messages tend to end in spam; Gmail and Yahoo also ask for DMARC from whoever sends a lot.
122
+ */
123
+ export async function checkSenderDomain(domain, providerId) {
124
+ const resolver = new Resolver({ timeout: 3000, tries: 2 });
125
+ const provider = providerById(providerId);
126
+ const checks = [];
127
+ const spf = (await txt(resolver, domain)).filter((r) => /^v=spf1\b/i.test(r));
128
+ if (!spf.length)
129
+ checks.push({
130
+ id: 'spf',
131
+ 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.'}`,
134
+ });
135
+ else if (spf.length > 1)
136
+ checks.push({
137
+ id: 'spf',
138
+ 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.',
141
+ });
142
+ else if (!/\b(include:|a\b|a:|mx\b|mx:|ip4:|ip6:|exists:|redirect=)/i.test(spf[0]))
143
+ checks.push({
144
+ id: 'spf',
145
+ 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]})` : ''}.`,
148
+ });
149
+ else if (provider?.spf && !provider.spf.some((h) => spf[0].includes(`include:${h}`)))
150
+ checks.push({
151
+ id: 'spf',
152
+ 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]}`,
155
+ });
156
+ else
157
+ checks.push({ id: 'spf', state: 'ok', title: 'Record SPF presente', detail: spf[0] });
158
+ const selectors = provider ? provider.dkim : COMMON_DKIM;
159
+ if (provider && !selectors.length)
160
+ checks.push({
161
+ id: 'dkim',
162
+ 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.`,
165
+ });
166
+ else {
167
+ let found = null;
168
+ // An empty key (p=) is a revoked one: it means "no signature".
169
+ for (const s of selectors)
170
+ if ((await txt(resolver, `${s}._domainkey.${domain}`)).some((r) => /\bp=[A-Za-z0-9+/]/.test(r))) {
171
+ found = s;
172
+ break;
173
+ }
174
+ checks.push(found
175
+ ? {
176
+ id: 'dkim',
177
+ state: 'ok',
178
+ title: 'Firma DKIM presente',
179
+ detail: `Trovata con il nome ${found}._domainkey.${domain}.`,
180
+ }
181
+ : {
182
+ id: 'dkim',
183
+ 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.`,
186
+ });
187
+ }
188
+ const dmarc = (await txt(resolver, `_dmarc.${domain}`)).find((r) => /^v=DMARC1\b/i.test(r));
189
+ if (!dmarc)
190
+ checks.push({
191
+ id: 'dmarc',
192
+ 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`,
195
+ });
196
+ else {
197
+ const policy = /\bp=(none|quarantine|reject)/i.exec(dmarc)?.[1]?.toLowerCase();
198
+ checks.push({
199
+ id: 'dmarc',
200
+ state: 'ok',
201
+ title: 'Record DMARC presente',
202
+ detail: policy === 'none' ? `Osserva soltanto (p=none): ${dmarc}` : dmarc,
203
+ });
204
+ }
205
+ return checks;
206
+ }
@@ -0,0 +1,52 @@
1
+ import type { Backups } from './backups.ts';
2
+ import { type RemoteStorage, type S3Target, type WebDavTarget } from './remote-storage.ts';
3
+ /**
4
+ * A copy of the backups outside the server, encrypted before it leaves: every night the newest
5
+ * database copy, and the media and fonts not sent yet (uploaded files never change, so they go
6
+ * once). On the storage the media are only added; database copies beyond `keep` are deleted.
7
+ *
8
+ * Encryption: AES-256-GCM with a random key made by Materialize, the "recovery key", shown once
9
+ * to the administrator. Whoever reads the bucket or the WebDAV folder sees noise. Files are
10
+ * encrypted in blocks of 1 MiB, each authenticated, the last one marked: a file cut short (an
11
+ * upload that stopped) is refused instead of giving back half a database.
12
+ */
13
+ export interface OffsiteSettings {
14
+ kind: 'off' | 's3' | 'webdav';
15
+ s3?: S3Target & {
16
+ prefix?: string;
17
+ };
18
+ webdav?: WebDavTarget;
19
+ /** How many database copies stay on the storage; older ones are deleted. */
20
+ keep: number;
21
+ /** The recovery key: base64url of 32 random bytes, with the "mzk1-" mark. */
22
+ key?: string;
23
+ }
24
+ export interface OffsiteStatus {
25
+ at: string;
26
+ ok: boolean;
27
+ message?: string;
28
+ database?: string;
29
+ media?: number;
30
+ fonts?: number;
31
+ }
32
+ export declare const DEFAULT_OFFSITE: OffsiteSettings;
33
+ /** Encrypted files end with this, so they are told apart from anything else in the folder. */
34
+ export declare const SUFFIX = ".mzb";
35
+ export declare function newRecoveryKey(): string;
36
+ export declare function parseRecoveryKey(text: string): Buffer;
37
+ /** Encrypts `source` into `target` (block by block: large videos never sit in memory). */
38
+ export declare function encryptFile(source: string, target: string, key: Buffer): Promise<void>;
39
+ /** The other way; throws on a wrong key, a changed file or one cut short. */
40
+ export declare function decryptFile(source: string, target: string, key: Buffer): Promise<void>;
41
+ export declare function storageOf(s: OffsiteSettings): RemoteStorage | null;
42
+ /**
43
+ * One run: what is missing goes up, old database copies go away. `fresh` makes a new database
44
+ * copy first (the "Esegui ora" button); otherwise the newest daily copy is sent.
45
+ */
46
+ export declare function runOffsite(s: OffsiteSettings, backups: Backups, mediaDir: string, fresh?: boolean,
47
+ /** For tests: a storage in memory. */
48
+ storage?: RemoteStorage | null): Promise<OffsiteStatus>;
49
+ /** "Prova il collegamento": writes, reads back, lists and deletes a small file. */
50
+ export declare function testOffsite(s: OffsiteSettings): Promise<void>;
51
+ /** Every encrypted file under a folder (or one file), decrypted next to it without the suffix. */
52
+ export declare function decryptTree(source: string, key: Buffer): Promise<string[]>;
package/src/offsite.js ADDED
@@ -0,0 +1,230 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative, resolve } from 'node:path';
5
+ import { createS3Storage, createWebDavStorage, } from './remote-storage.js';
6
+ export const DEFAULT_OFFSITE = { kind: 'off', keep: 30 };
7
+ const KEY_MARK = 'mzk1-';
8
+ const MAGIC = Buffer.from('MZB1');
9
+ const BLOCK = 1024 * 1024;
10
+ /** Encrypted files end with this, so they are told apart from anything else in the folder. */
11
+ export const SUFFIX = '.mzb';
12
+ export function newRecoveryKey() {
13
+ return KEY_MARK + randomBytes(32).toString('base64url');
14
+ }
15
+ export function parseRecoveryKey(text) {
16
+ const clean = text.trim().replace(/\s+/g, '');
17
+ const raw = clean.startsWith(KEY_MARK) ? clean.slice(KEY_MARK.length) : clean;
18
+ const key = Buffer.from(raw, 'base64url');
19
+ if (key.length !== 32)
20
+ throw new Error('La chiave di ripristino non è valida: deve iniziare con mzk1-');
21
+ return key;
22
+ }
23
+ const nonceOf = (prefix, index) => {
24
+ const n = Buffer.alloc(12);
25
+ prefix.copy(n, 0);
26
+ n.writeUInt32BE(index, 8);
27
+ return n;
28
+ };
29
+ /** Encrypts `source` into `target` (block by block: large videos never sit in memory). */
30
+ export async function encryptFile(source, target, key) {
31
+ const input = await open(source, 'r');
32
+ const output = await open(target, 'w');
33
+ try {
34
+ const { size } = await input.stat();
35
+ const blocks = Math.max(1, Math.ceil(size / BLOCK));
36
+ const prefix = randomBytes(8);
37
+ await output.write(Buffer.concat([MAGIC, prefix]));
38
+ const buffer = Buffer.alloc(BLOCK);
39
+ for (let i = 0; i < blocks; i++) {
40
+ const { bytesRead } = await input.read(buffer, 0, BLOCK, i * BLOCK);
41
+ const cipher = createCipheriv('aes-256-gcm', key, nonceOf(prefix, i));
42
+ cipher.setAAD(Buffer.from([i === blocks - 1 ? 1 : 0]));
43
+ const sealed = Buffer.concat([cipher.update(buffer.subarray(0, bytesRead)), cipher.final(), cipher.getAuthTag()]);
44
+ const length = Buffer.alloc(4);
45
+ length.writeUInt32BE(bytesRead);
46
+ await output.write(Buffer.concat([length, sealed]));
47
+ }
48
+ }
49
+ finally {
50
+ await input.close();
51
+ await output.close();
52
+ }
53
+ }
54
+ /** The other way; throws on a wrong key, a changed file or one cut short. */
55
+ export async function decryptFile(source, target, key) {
56
+ const input = await open(source, 'r');
57
+ const output = await open(target, 'w');
58
+ let ok = false;
59
+ try {
60
+ const head = Buffer.alloc(12);
61
+ await input.read(head, 0, 12, 0);
62
+ if (!head.subarray(0, 4).equals(MAGIC))
63
+ throw new Error('Non è un file di backup cifrato da Materialize');
64
+ const prefix = head.subarray(4, 12);
65
+ let position = 12;
66
+ for (let i = 0;; i++) {
67
+ const length = Buffer.alloc(4);
68
+ if ((await input.read(length, 0, 4, position)).bytesRead < 4)
69
+ throw new Error('Il file è incompleto');
70
+ const size = length.readUInt32BE();
71
+ if (size > BLOCK)
72
+ throw new Error('Il file è danneggiato');
73
+ const sealed = Buffer.alloc(size + 16);
74
+ if ((await input.read(sealed, 0, size + 16, position + 4)).bytesRead < size + 16)
75
+ throw new Error('Il file è incompleto');
76
+ position += 4 + size + 16;
77
+ const end = position >= (await input.stat()).size;
78
+ const decipher = createDecipheriv('aes-256-gcm', key, nonceOf(prefix, i));
79
+ decipher.setAAD(Buffer.from([end ? 1 : 0]));
80
+ decipher.setAuthTag(sealed.subarray(size));
81
+ try {
82
+ await output.write(Buffer.concat([decipher.update(sealed.subarray(0, size)), decipher.final()]));
83
+ }
84
+ catch {
85
+ throw new Error(end ? 'Chiave sbagliata, oppure il file è stato modificato' : 'Il file è incompleto o è stato modificato');
86
+ }
87
+ if (end)
88
+ break;
89
+ }
90
+ ok = true;
91
+ }
92
+ finally {
93
+ await input.close();
94
+ await output.close();
95
+ if (!ok)
96
+ await rm(target, { force: true });
97
+ }
98
+ }
99
+ export function storageOf(s) {
100
+ if (s.kind === 's3' && s.s3)
101
+ return createS3Storage(s.s3, s.s3.prefix ?? '');
102
+ if (s.kind === 'webdav' && s.webdav)
103
+ return createWebDavStorage(s.webdav);
104
+ return null;
105
+ }
106
+ const README = `Backup di Materialize
107
+
108
+ Questi file sono cifrati (AES-256-GCM): senza la chiave di ripristino non si leggono.
109
+
110
+ database/ le copie del database, dalla più recente
111
+ media/ le immagini e i file caricati
112
+ fonts/ i caratteri del sito
113
+
114
+ Per rimetterli su un server:
115
+ 1. scarica la cartella (con lo strumento del servizio, per esempio rclone);
116
+ 2. nella cartella del sito: npx materialize decrypt <cartella scaricata> --key <chiave>
117
+ 3. npx materialize restore <cartella decifrata>/database/content-….db
118
+ e copia media/ e fonts/ in .materialize/
119
+
120
+ https://github.com/materializecms/materialize/blob/main/docs/guida/12-aggiornamenti-e-backup.md
121
+ `;
122
+ /** Every file under a folder, as paths relative to it; hidden files and the media cache left out. */
123
+ async function filesUnder(dir) {
124
+ const out = [];
125
+ const walk = async (current) => {
126
+ for (const item of await readdir(current, { withFileTypes: true }).catch(() => [])) {
127
+ if (item.name.startsWith('.'))
128
+ continue;
129
+ const path = join(current, item.name);
130
+ if (item.isDirectory())
131
+ await walk(path);
132
+ else if (item.isFile())
133
+ out.push(relative(dir, path).split('\\').join('/'));
134
+ }
135
+ };
136
+ await walk(dir);
137
+ return out;
138
+ }
139
+ /**
140
+ * One run: what is missing goes up, old database copies go away. `fresh` makes a new database
141
+ * copy first (the "Esegui ora" button); otherwise the newest daily copy is sent.
142
+ */
143
+ export async function runOffsite(s, backups, mediaDir, fresh = false,
144
+ /** For tests: a storage in memory. */
145
+ storage = storageOf(s)) {
146
+ if (!storage || !s.key)
147
+ throw new Error('La copia fuori dal server non è configurata');
148
+ const key = parseRecoveryKey(s.key);
149
+ const work = await mkdtemp(join(tmpdir(), 'materialize-offsite-'));
150
+ try {
151
+ const remote = new Set((await storage.list('')).map((i) => i.key));
152
+ if (!remote.has('LEGGIMI.txt'))
153
+ await storage.putText('LEGGIMI.txt', README);
154
+ const send = async (local, remoteKey) => {
155
+ const sealed = join(work, 'file');
156
+ await encryptFile(local, sealed, key);
157
+ await storage.put(remoteKey, sealed);
158
+ await rm(sealed, { force: true });
159
+ };
160
+ let database;
161
+ if (backups.available()) {
162
+ const latest = fresh ? await backups.create('esterno') : (await backups.list())[0];
163
+ if (latest) {
164
+ database = latest.name;
165
+ const target = `database/${latest.name}${SUFFIX}`;
166
+ if (!remote.has(target))
167
+ await send(join(backups.folder(), latest.name), target);
168
+ }
169
+ }
170
+ const media = resolve(mediaDir);
171
+ let mediaSent = 0;
172
+ for (const name of await filesUnder(media)) {
173
+ const target = `media/${name}${SUFFIX}`;
174
+ if (remote.has(target))
175
+ continue;
176
+ await send(join(media, name), target);
177
+ mediaSent++;
178
+ }
179
+ const fonts = join(resolve(mediaDir), '..', 'fonts');
180
+ let fontsSent = 0;
181
+ for (const name of await filesUnder(fonts)) {
182
+ const target = `fonts/${name}${SUFFIX}`;
183
+ if (remote.has(target))
184
+ continue;
185
+ await send(join(fonts, name), target);
186
+ fontsSent++;
187
+ }
188
+ // The newest copies stay; the media are never deleted (an old copy of the database may use them).
189
+ const copies = (await storage.list('database/'))
190
+ .filter((i) => i.key.endsWith(SUFFIX))
191
+ .sort((a, b) => b.key.localeCompare(a.key));
192
+ for (const old of copies.slice(Math.max(1, s.keep)))
193
+ await storage.delete(old.key);
194
+ return { at: new Date().toISOString(), ok: true, database, media: mediaSent, fonts: fontsSent };
195
+ }
196
+ finally {
197
+ await rm(work, { recursive: true, force: true });
198
+ }
199
+ }
200
+ /** "Prova il collegamento": writes, reads back, lists and deletes a small file. */
201
+ export async function testOffsite(s) {
202
+ const storage = storageOf(s);
203
+ if (!storage)
204
+ throw new Error('Scegli S3 o WebDAV e compila i campi');
205
+ const key = `.materialize-prova-${randomBytes(4).toString('hex')}.txt`;
206
+ const text = `Prova di Materialize, ${new Date().toISOString()}`;
207
+ await storage.putText(key, text);
208
+ try {
209
+ if ((await storage.getText(key)) !== text)
210
+ throw new Error('Il file di prova è tornato diverso');
211
+ await storage.list('');
212
+ }
213
+ finally {
214
+ await storage.delete(key);
215
+ }
216
+ }
217
+ /** Every encrypted file under a folder (or one file), decrypted next to it without the suffix. */
218
+ export async function decryptTree(source, key) {
219
+ const info = await stat(source);
220
+ const files = info.isDirectory()
221
+ ? (await filesUnder(source)).filter((f) => f.endsWith(SUFFIX)).map((f) => join(source, f))
222
+ : [source];
223
+ const done = [];
224
+ for (const file of files) {
225
+ const target = file.endsWith(SUFFIX) ? file.slice(0, -SUFFIX.length) : `${file}.decifrato`;
226
+ await decryptFile(file, target, key);
227
+ done.push(target);
228
+ }
229
+ return done;
230
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Storage outside the server for the backups: any S3-compatible service (Amazon S3, Cloudflare
3
+ * R2, Backblaze B2, Hetzner, Scaleway, OVH, Wasabi, MinIO…) or a WebDAV folder (Nextcloud,
4
+ * ownCloud, Synology and QNAP, pCloud, kDrive…). Written with fetch and node:crypto only.
5
+ */
6
+ export interface S3Target {
7
+ /** https://… of the service, without the bucket: https://<account>.r2.cloudflarestorage.com */
8
+ endpoint: string;
9
+ region: string;
10
+ bucket: string;
11
+ accessKeyId: string;
12
+ secretAccessKey: string;
13
+ }
14
+ export interface WebDavTarget {
15
+ /** The folder the backups go in: https://cloud.example.com/remote.php/dav/files/nome/backup/ */
16
+ url: string;
17
+ user: string;
18
+ password: string;
19
+ }
20
+ export interface RemoteItem {
21
+ key: string;
22
+ size: number;
23
+ modified: Date;
24
+ }
25
+ export interface RemoteStorage {
26
+ put(key: string, file: string): Promise<void>;
27
+ putText(key: string, text: string): Promise<void>;
28
+ get(key: string, file: string): Promise<void>;
29
+ getText(key: string): Promise<string>;
30
+ /** Everything under a folder of the storage ("database/", "media/"), with sizes. */
31
+ list(prefix: string): Promise<RemoteItem[]>;
32
+ delete(key: string): Promise<void>;
33
+ }
34
+ export declare class RemoteError extends Error {
35
+ readonly status?: number;
36
+ constructor(message: string, status?: number);
37
+ }
38
+ /**
39
+ * A request signed with AWS Signature Version 4, which every S3-compatible service accepts. The
40
+ * body is not hashed (UNSIGNED-PAYLOAD): files are streamed over HTTPS, as large as they are.
41
+ */
42
+ export declare function signS3(target: S3Target, method: string, key: string, query?: Record<string, string>, headers?: Record<string, string>, now?: Date, payloadHash?: string): {
43
+ url: string;
44
+ headers: Record<string, string>;
45
+ };
46
+ export declare function createS3Storage(target: S3Target, prefix?: string): RemoteStorage;
47
+ export declare function createWebDavStorage(target: WebDavTarget): RemoteStorage;
@@ -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(/&lt;/g, '<')
103
+ .replace(/&gt;/g, '>')
104
+ .replace(/&quot;/g, '"')
105
+ .replace(/&apos;/g, "'")
106
+ .replace(/&amp;/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
+ }