@materializecms/server 0.1.0 → 0.2.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,14 @@ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Pros
4
4
 
5
5
  ## Prossima versione
6
6
 
7
+ ## 0.2.0 — 2026-11-01
8
+
9
+ - **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.
10
+ - **Cronologia per le pagine sulla tela**: ogni versione si guarda su desktop, tablet e mobile prima di ripristinarla.
11
+ - **Esporta la pagina in PDF o PNG** dall'editor, per mandarla al cliente: desktop, tablet e mobile, ognuno come un'unica immagine lunga.
12
+ - **Guida per chi usa Materialize**, in `docs/guida`: installazione, editor, contenuti, lingue, SEO, persone, aggiornamenti, backup e problemi comuni.
13
+ - **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.
14
+
7
15
  ## 0.1.0 — 2026-10-31
8
16
 
9
17
  La prima versione pubblica.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.2.0",
24
+ "@materializecms/core": "0.2.0",
25
+ "@materializecms/db": "0.2.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
@@ -52,6 +52,11 @@ const CHALLENGE_TTL = 5 * 60 * 1000;
52
52
  const dummyHash = hashPassword(randomBytes(16).toString('hex'));
53
53
  const INVITE_TTL = 7 * 24 * 60 * 60 * 1000;
54
54
  const RESET_TTL = 24 * 60 * 60 * 1000;
55
+ /** A link asked for from the sign-in page: shorter, since nobody vouched for the request. */
56
+ const FORGOT_TTL = 60 * 60 * 1000;
57
+ /** Links asked for per email, and per address, before a pause: no one is flooded with emails. */
58
+ const FORGOT_THRESHOLD = 3;
59
+ const FORGOT_IP_THRESHOLD = 10;
55
60
  /** Where people open their one-time link: the admin's welcome screen. */
56
61
  const linkPath = (token) => `/admin/_invite/${token}`;
57
62
  /** The custom code blocks in a document: it runs on the public site, so only admins may change it. */
@@ -643,6 +648,40 @@ export function createApp(base) {
643
648
  const { passwordHash: _, ...user } = found;
644
649
  return signIn(c, user);
645
650
  });
651
+ /** For the sign-in page: whether "Password dimenticata?" can send an email, or someone has to help. */
652
+ app.get('/api/auth/recovery', async (c) => c.json({ mail: isConfigured(await settings.get('mail')) }));
653
+ /**
654
+ * A forgotten password: a one-time link by email, valid an hour. The answer is the same whether
655
+ * the email has an account or not, so nobody can learn who is registered; requests per email and
656
+ * per address are limited, so nobody can flood an inbox.
657
+ */
658
+ app.post('/api/auth/forgot', async (c) => {
659
+ const input = await body(c, z.object({ email: z.email() }));
660
+ const email = input.email.trim().toLowerCase();
661
+ const ip = clientIp(c);
662
+ await checkLocks(`forgot:${email}`, `forgot-ip:${ip}`);
663
+ await auth.recordFailure(`forgot:${email}`, FORGOT_THRESHOLD);
664
+ await auth.recordFailure(`forgot-ip:${ip}`, FORGOT_IP_THRESHOLD);
665
+ const mail = isConfigured(await settings.get('mail'));
666
+ const found = mail ? await auth.findForLogin(email) : null;
667
+ if (found) {
668
+ const { token } = await auth.createInvite({
669
+ kind: 'reset',
670
+ email: found.email,
671
+ name: found.name,
672
+ role: found.role,
673
+ userId: found.id,
674
+ ttlMs: FORGOT_TTL,
675
+ });
676
+ await mailLink(c, found.email, 'Nuova password', [
677
+ `Qualcuno ha chiesto una nuova password per il tuo account su ${ctx.config().site.name}.`,
678
+ 'Se non sei stato tu, ignora questa email: la password resta quella di prima.',
679
+ 'Per sceglierne una nuova, apri questo link entro un’ora:',
680
+ ], linkPath(token));
681
+ await record(c, 'password.forgot', found.email, null, { id: found.id, name: found.name });
682
+ }
683
+ return c.json({ ok: true, mail });
684
+ });
646
685
  /** The second step: a code from the app, or a recovery code. */
647
686
  app.post('/api/auth/login/verify', async (c) => {
648
687
  const input = await body(c, z.object({ ticket: z.string().min(1).max(100), code: z.string().trim().min(6).max(20) }));
@@ -770,7 +809,7 @@ export function createApp(base) {
770
809
  app.get('/api/security', requireUser, requireAdmin, async (c) => c.json({ requireTwoFactor: await twoFactorRequired() }));
771
810
  /** Groups of the activity log, by the start of their action codes. */
772
811
  const ACTIVITY_GROUPS = {
773
- access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.'],
812
+ access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
774
813
  people: ['user.'],
775
814
  content: ['document.', 'media.', 'message.'],
776
815
  settings: ['settings.', 'backup.', 'update.'],
@@ -1448,12 +1487,26 @@ export function createApp(base) {
1448
1487
  return false;
1449
1488
  }
1450
1489
  };
1490
+ /**
1491
+ * The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
1492
+ * is known. The request's Host header is used only without it, as in local development: anyone
1493
+ * can send a request with a false Host, and a link to their server would carry the token there.
1494
+ */
1495
+ const publicOrigin = (c) => {
1496
+ const site = ctx.config().site.url;
1497
+ try {
1498
+ if (site)
1499
+ return new URL(site).origin;
1500
+ }
1501
+ catch { }
1502
+ return new URL(c.req.url).origin;
1503
+ };
1451
1504
  const mailLink = async (c, to, subject, lines, path) => {
1452
1505
  const mail = await settings.get('mail');
1453
1506
  if (!isConfigured(mail))
1454
1507
  return false;
1455
1508
  try {
1456
- const url = new URL(path, new URL(c.req.url).origin).toString();
1509
+ const url = new URL(path, publicOrigin(c)).toString();
1457
1510
  await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
1458
1511
  return true;
1459
1512
  }