@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/app.js CHANGED
@@ -8,8 +8,9 @@ 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 { currentLang, intlLocaleOf, isLang } from '@materializecms/core/lang';
11
12
  import { senderDomain } from '@materializecms/core/mail';
12
- import { expandComponents, expandGlobals, formDefinition, normalizeTheme, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
13
+ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, OVERRIDE_KEYS, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
13
14
  import { addMove, feedCollections, normalizePath } from '@materializecms/core/seo';
14
15
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
15
16
  import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
@@ -31,6 +32,7 @@ import { createBackups, DEFAULT_BACKUPS } from './backups.js';
31
32
  import { briefSystem, briefTools, plain } from './brief.js';
32
33
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
33
34
  import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
35
+ import { requestLang, useProfileLang, withLang } from './lang.js';
34
36
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
35
37
  import { createMcpServer } from './mcp.js';
36
38
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
@@ -40,6 +42,7 @@ import { hashPassword, verifyPassword } from './password.js';
40
42
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
41
43
  import { statusPage } from './status-page.js';
42
44
  import { studioSystem, studioTools } from './studio.js';
45
+ import { t } from './t.js';
43
46
  import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
44
47
  import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
45
48
  import { readRest, readWxr, WordPressError } from './wordpress.js';
@@ -78,32 +81,80 @@ function embedsOf(value, out = []) {
78
81
  const node = value;
79
82
  if (node.type === 'embed' && typeof node.id === 'string')
80
83
  out.push(`${node.id}:${String(node.html ?? '')}`);
81
- // Where a form's messages go: someone who only edits pages must not divert them.
84
+ // Where a form's messages go, and the email it sends back to whoever writes (to any address a
85
+ // visitor types): someone who only edits pages must not divert them or write that email.
82
86
  const form = node.form;
83
- if (form && typeof node.id === 'string' && (form.to || form.webhook))
84
- out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}`);
87
+ if (form && typeof node.id === 'string' && (form.to || form.webhook || form.autoReply))
88
+ out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}:${JSON.stringify(form.autoReply ?? null)}`);
89
+ // A component's copy keeps only its own words, links and images; anything else in its overrides
90
+ // (a type, custom code, a form) would turn an element of the component into something the
91
+ // checks above never see by its id.
92
+ const overrides = node.overrides;
93
+ if (overrides && typeof overrides === 'object' && typeof node.id === 'string')
94
+ for (const [child, own] of Object.entries(overrides)) {
95
+ const extra = own && typeof own === 'object'
96
+ ? Object.keys(own).filter((k) => !OVERRIDE_KEYS.includes(k))
97
+ : [];
98
+ if (extra.length)
99
+ out.push(`override:${node.id}:${child}:${JSON.stringify(Object.fromEntries(extra.map((k) => [k, own[k]])))}`);
100
+ }
85
101
  for (const v of Object.values(node))
86
102
  if (v && typeof v === 'object')
87
103
  embedsOf(v, out);
88
104
  }
89
105
  return out;
90
106
  }
107
+ /** What a form shows to visitors: the rest (who receives it, the webhook, the automatic reply) is the site's own. */
108
+ const PUBLIC_FORM_KEYS = ['name', 'success', 'newsletter'];
109
+ /**
110
+ * A published document as anyone may read it: page elements (and component copies' overrides)
111
+ * keep only the public part of their forms. The site itself reads the database, not this.
112
+ */
113
+ function withoutFormSettings(value) {
114
+ if (Array.isArray(value))
115
+ return value.map(withoutFormSettings);
116
+ if (!value || typeof value !== 'object')
117
+ return value;
118
+ const out = {};
119
+ for (const [k, v] of Object.entries(value)) {
120
+ out[k] =
121
+ k === 'form' && v && typeof v === 'object' && !Array.isArray(v)
122
+ ? Object.fromEntries(Object.entries(v).filter(([key]) => PUBLIC_FORM_KEYS.includes(key)))
123
+ : withoutFormSettings(v);
124
+ }
125
+ return out;
126
+ }
91
127
  class ApiError extends HTTPException {
92
128
  constructor(status, code, message, issues) {
93
129
  super(status, { res: Response.json({ error: { code, message, issues } }, { status }) });
94
130
  }
95
131
  }
96
- const credentials = z.object({ email: z.email(), password: z.string().min(1) });
132
+ /** The language of an email to someone with an account: their profile's, else the current one. */
133
+ /**
134
+ * The language of an email to someone with an account: their profile's, else Italian, the panel's
135
+ * default — never the language of whoever made the request (a stranger trying to sign in).
136
+ */
137
+ const langFor = (locale) => (isLang(locale) ? locale : 'it');
138
+ /** A document's schedules with the reason of the last failure in the reader's language (t.keep). */
139
+ const readSchedule = (schedule) => schedule.failed?.error
140
+ ? { ...schedule, failed: { ...schedule.failed, error: t.read(schedule.failed.error) } }
141
+ : schedule;
142
+ /** Longer than any real password: hashing a huge one would only cost the server time. */
143
+ const MAX_PASSWORD = 1000;
144
+ const credentials = z.object({ email: z.email(), password: z.string().min(1).max(MAX_PASSWORD) });
97
145
  const setupBody = z.object({
98
146
  email: z.email(),
99
147
  name: z.string().trim().min(1),
100
- password: z.string().min(10),
148
+ password: z.string().min(10).max(MAX_PASSWORD),
101
149
  /** From the link `materialize start` prints, when it set one. */
102
150
  token: z.string().optional(),
103
151
  site: z
104
152
  .object({
105
153
  name: z.string().trim().min(1).max(120).optional(),
106
- locale: z.string().refine(isLocaleCode, 'Codice lingua non valido').optional(),
154
+ locale: z
155
+ .string()
156
+ .refine(isLocaleCode, { error: () => t('Codice lingua non valido') })
157
+ .optional(),
107
158
  })
108
159
  .optional(),
109
160
  });
@@ -168,8 +219,17 @@ async function body(c, schema) {
168
219
  /** Naive in-memory limiter: enough to slow down password guessing on a single instance. */
169
220
  function createLimiter(max, windowMs) {
170
221
  const hits = new Map();
222
+ let swept = Date.now();
171
223
  return (key) => {
172
224
  const now = Date.now();
225
+ // Keys come from whoever asks (an address, a form's name): the expired ones go, or the map
226
+ // would only grow.
227
+ if (now - swept > windowMs) {
228
+ swept = now;
229
+ for (const [k, v] of hits)
230
+ if (v.until < now)
231
+ hits.delete(k);
232
+ }
173
233
  const entry = hits.get(key);
174
234
  if (!entry || entry.until < now) {
175
235
  hits.set(key, { n: 1, until: now + windowMs });
@@ -179,6 +239,28 @@ function createLimiter(max, windowMs) {
179
239
  return entry.n <= max;
180
240
  };
181
241
  }
242
+ /** Request bodies, at most: anyone may send one, and it is read into memory. */
243
+ const BODY_MAX = 2 * 1024 * 1024;
244
+ /** For the panel and its tokens: a page built on the canvas, with its components, can weigh a few MB. */
245
+ const SIGNED_IN_BODY_MAX = 10 * 1024 * 1024;
246
+ /** A WordPress export file (WXR): only text, but a large blog's can be big. */
247
+ const WXR_MAX = 200 * 1024 * 1024;
248
+ /** Two addresses of the same server (scheme, host and port), or both empty (the provider's own). */
249
+ const sameServer = (a, b) => {
250
+ if (!a || !b)
251
+ return !a && !b;
252
+ try {
253
+ return new URL(a).origin === new URL(b).origin;
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ };
259
+ /** This computer: a Host header that can only bring a link back here, never to someone else's server. */
260
+ const isLocalHost = (hostname) => hostname === 'localhost' ||
261
+ hostname.endsWith('.localhost') ||
262
+ /^127\.\d+\.\d+\.\d+$/.test(hostname) ||
263
+ hostname === '[::1]';
182
264
  export function createApp(base) {
183
265
  // The languages chosen in Impostazioni → Lingue win over the config's; read once, then kept current.
184
266
  let languages = null;
@@ -228,6 +310,15 @@ export function createApp(base) {
228
310
  ...DEFAULT_OFFSITE,
229
311
  ...(await settings.get('offsite')),
230
312
  });
313
+ /**
314
+ * The language of the people who get the site's notices (a form's messages): the account with
315
+ * that address, else the first administrator's, else Italian.
316
+ */
317
+ const ownerLang = async (address) => {
318
+ const users = await auth.listUsers();
319
+ const owner = users.find((u) => u.email.toLowerCase() === address.toLowerCase()) ?? users.find((u) => u.role === 'admin');
320
+ return langFor(owner?.locale);
321
+ };
231
322
  let offsiteRunning = false;
232
323
  /** One run; the outcome is kept, and administrators hear by email the first time it fails. */
233
324
  const offsiteRun = async (fresh) => {
@@ -243,10 +334,13 @@ export function createApp(base) {
243
334
  status = { at: new Date().toISOString(), ok: false, message: error.message };
244
335
  if (before?.ok !== false)
245
336
  for (const admin of (await auth.listUsers()).filter((u) => u.role === 'admin'))
246
- await mailNotice(admin.email, 'Backup fuori dal server non riuscito', [
247
- `La copia dei backup di ${ctx.config().site.name} fuori dal server non è riuscita: ${status.message}`,
248
- 'Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.',
249
- ]);
337
+ await withLang(langFor(admin.locale), () => mailNotice(admin.email, t('Backup fuori dal server non riuscito'), [
338
+ t('La copia dei backup di {site} fuori dal server non è riuscita: {message}', {
339
+ site: ctx.config().site.name,
340
+ message: status.message,
341
+ }),
342
+ t('Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.'),
343
+ ]));
250
344
  }
251
345
  finally {
252
346
  offsiteRunning = false;
@@ -323,6 +417,12 @@ export function createApp(base) {
323
417
  const presence = new Map();
324
418
  // A form can be sent a few times a minute from one address: enough for people, not for spam.
325
419
  const allowForm = createLimiter(5, 60_000);
420
+ // And from one address across every form: form names are whatever the sender writes.
421
+ const allowFormIp = createLimiter(10, 60_000);
422
+ const allowFormIpHour = createLimiter(60, 60 * 60_000);
423
+ // Forms the site built by hand (no form on a published page): any name goes, so fewer of them.
424
+ const allowHandForm = createLimiter(3, 60_000);
425
+ const privateWebhooks = base.privateWebhooks ?? process.env.MATERIALIZE_PRIVATE_WEBHOOKS === '1';
326
426
  const files = createMediaFiles(ctx.config().media.dir);
327
427
  // Fonts are cached next to the media library (.materialize/fonts by default).
328
428
  const fonts = createFonts(join(dirname(resolve(ctx.config().media.dir)), 'fonts'));
@@ -339,11 +439,11 @@ export function createApp(base) {
339
439
  if (user.role === 'admin')
340
440
  return;
341
441
  if (JSON.stringify(embedsOf(before).sort()) !== JSON.stringify(embedsOf(after).sort()))
342
- throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli');
442
+ throw new ApiError(403, 'embed_admin_only', t('Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli'));
343
443
  };
344
444
  /** Editors who may only change contents (Persone → Redattori): words, links and images, not the layout. */
345
445
  const contentOnly = async (user) => user.role !== 'admin' && !!(await settings.get('editorial'))?.editorsContentOnly;
346
- const designLocked = () => new ApiError(403, 'content_only', 'Puoi modificare testi, collegamenti e immagini, non il layout della pagina');
446
+ const designLocked = () => new ApiError(403, 'content_only', t('Puoi modificare testi, collegamenti e immagini, non il layout della pagina'));
347
447
  const guardDesign = async (user, before, after) => {
348
448
  const pages = (v) => (Array.isArray(v) ? v : []);
349
449
  if (!sameDesign(pages(before), pages(after)) && (await contentOnly(user)))
@@ -399,6 +499,19 @@ export function createApp(base) {
399
499
  console.error('Materialize: activity not recorded', error);
400
500
  }
401
501
  };
502
+ /**
503
+ * Whether the browser reached the site over HTTPS: directly, through a proxy the config trusts,
504
+ * or at a site whose address is https (not on this computer, where browsers may refuse a secure
505
+ * cookie over plain http). The session cookie then never travels in clear.
506
+ */
507
+ const secureRequest = (c) => {
508
+ const url = new URL(c.req.url);
509
+ if (url.protocol === 'https:')
510
+ return true;
511
+ if (ctx.config().server.trustProxy && c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() === 'https')
512
+ return true;
513
+ return !isLocalHost(url.hostname) && !!ctx.config().site.url?.startsWith('https:');
514
+ };
402
515
  const startSession = async (c, userId) => {
403
516
  const { token, expiresAt } = await auth.createSession(userId, SESSION_TTL, {
404
517
  userAgent: c.req.header('user-agent'),
@@ -407,7 +520,7 @@ export function createApp(base) {
407
520
  setCookie(c, SESSION_COOKIE, token, {
408
521
  httpOnly: true,
409
522
  sameSite: 'Lax',
410
- secure: new URL(c.req.url).protocol === 'https:',
523
+ secure: secureRequest(c),
411
524
  path: '/',
412
525
  expires: expiresAt,
413
526
  });
@@ -424,6 +537,7 @@ export function createApp(base) {
424
537
  throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
425
538
  c.set('user', user);
426
539
  c.set('viaToken', true);
540
+ useProfileLang(user.locale);
427
541
  return next();
428
542
  }
429
543
  const token = getCookie(c, SESSION_COOKIE);
@@ -431,6 +545,7 @@ export function createApp(base) {
431
545
  if (!user)
432
546
  throw new ApiError(401, 'unauthenticated', 'Sign in to continue');
433
547
  c.set('user', user);
548
+ useProfileLang(user.locale);
434
549
  // When the site requires two-step verification, someone without it can only set it up.
435
550
  if (!user.twoFactor && !c.req.path.startsWith('/api/auth/') && (await twoFactorRequired()))
436
551
  throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
@@ -466,7 +581,7 @@ export function createApp(base) {
466
581
  url: def?.url ? def.url(doc.data) : null,
467
582
  issues: check && !check.ok ? check.issues : [],
468
583
  /** When it goes on or off the site by itself, and the last scheduled action that failed. */
469
- schedule: await schedules.forDocument(doc.id),
584
+ schedule: readSchedule(await schedules.forDocument(doc.id)),
470
585
  /** The release it goes out with, if any. */
471
586
  release: await releases
472
587
  .forDocument(doc.id)
@@ -503,12 +618,39 @@ export function createApp(base) {
503
618
  throw error;
504
619
  }
505
620
  };
621
+ // Library files are the og:image of shared pages and the enclosures of the RSS feed: social previews
622
+ // and feed readers load them from other sites. Registered before secureHeaders, so this runs after it.
623
+ app.use('/media/*', async (c, next) => {
624
+ await next();
625
+ c.res.headers.set('cross-origin-resource-policy', 'cross-origin');
626
+ });
506
627
  app.use('*', secureHeaders());
628
+ // Every body is limited before anything reads it: the sign-in, setup and form routes are open to
629
+ // anyone. Uploads and the WordPress export set their own limits (larger, after the sign-in check).
630
+ const ownLimit = /^\/api\/(media|forms\/[^/]+|import\/wordpress\/analyze)$/;
631
+ const limitBody = (max) => bodyLimit({
632
+ maxSize: max,
633
+ onError: () => {
634
+ throw new ApiError(413, 'body_too_large', t('La richiesta è troppo grande'));
635
+ },
636
+ });
637
+ const smallBody = limitBody(BODY_MAX);
638
+ const signedInBody = limitBody(SIGNED_IN_BODY_MAX);
639
+ const limitRequest = (c, next) => {
640
+ if (c.req.method === 'POST' && ownLimit.test(c.req.path))
641
+ return next();
642
+ // Only the larger size for a request that says who sends it; the sign-in check comes after.
643
+ return (bearerOf(c) || getCookie(c, SESSION_COOKIE) ? signedInBody : smallBody)(c, next);
644
+ };
645
+ app.use('/api/*', limitRequest);
646
+ app.use('/mcp', limitRequest);
507
647
  // Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
508
648
  const checkOrigin = csrf();
509
649
  app.use('/api/*', (c, next) =>
510
650
  // Leaving the newsletter with one click from the email app comes with no Origin: the token is the proof.
511
651
  bearerOf(c) || c.req.path.startsWith('/api/newsletter/unsubscribe/') ? next() : checkOrigin(c, next));
652
+ // Every request in the language of whoever makes it (see lang.ts).
653
+ app.use('*', requestLang);
512
654
  // Answers change with every edit: browsers must not reuse them.
513
655
  app.use('/api/*', async (c, next) => {
514
656
  await next();
@@ -553,7 +695,7 @@ export function createApp(base) {
553
695
  id: 'node',
554
696
  label: `Node.js ${process.versions.node}`,
555
697
  state: node >= 24 ? 'ok' : 'error',
556
- detail: node >= 24 ? 'Versione adatta.' : 'Serve Node 24 o più recente.',
698
+ detail: node >= 24 ? t('Versione adatta.') : t('Serve Node 24 o più recente.'),
557
699
  });
558
700
  const mediaDir = resolve(ctx.config().media.dir);
559
701
  let writable = true;
@@ -566,41 +708,41 @@ export function createApp(base) {
566
708
  }
567
709
  checks.push({
568
710
  id: 'files',
569
- label: 'Cartella dei file',
711
+ label: t('Cartella dei file'),
570
712
  state: writable ? 'ok' : 'error',
571
713
  detail: writable
572
- ? 'Database e immagini si possono salvare.'
573
- : `Il programma non può scrivere in ${mediaDir}: dagli i permessi su quella cartella.`,
714
+ ? t('Database e immagini si possono salvare.')
715
+ : t('Il programma non può scrivere in {dir}: dagli i permessi su quella cartella.', { dir: mediaDir }),
574
716
  });
575
717
  checks.push({
576
718
  id: 'site',
577
- label: 'Sito',
719
+ label: t('Sito'),
578
720
  state: ctx.preview ? 'ok' : 'warn',
579
721
  detail: ctx.preview
580
- ? 'Il sito si genera da solo a ogni pubblicazione.'
581
- : 'Solo il CMS: in questa cartella non c’è un sito Astro.',
722
+ ? t('Il sito si genera da solo a ogni pubblicazione.')
723
+ : t('Solo il CMS: in questa cartella non c’è un sito Astro.'),
582
724
  });
583
725
  const url = new URL(c.req.url);
584
726
  const local = /^(localhost|127\.|\[::1\])/.test(url.hostname);
585
727
  const https = url.protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https';
586
728
  checks.push({
587
729
  id: 'https',
588
- label: 'Connessione sicura',
730
+ label: t('Connessione sicura'),
589
731
  state: https || local ? 'ok' : 'warn',
590
732
  detail: https
591
- ? 'HTTPS attivo.'
733
+ ? t('HTTPS attivo.')
592
734
  : local
593
- ? 'In locale non serve HTTPS.'
594
- : 'Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).',
735
+ ? t('In locale non serve HTTPS.')
736
+ : t('Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).'),
595
737
  });
596
738
  const updates = await ctx.updates?.status();
597
739
  checks.push({
598
740
  id: 'updates',
599
- label: 'Aggiornamenti',
741
+ label: t('Aggiornamenti'),
600
742
  state: updates && !updates.unavailable ? 'ok' : 'warn',
601
743
  detail: updates && !updates.unavailable
602
- ? 'Si installano dal pannello, con un clic.'
603
- : (updates?.unavailable ?? 'Si installano da terminale.'),
744
+ ? t('Si installano dal pannello, con un clic.')
745
+ : (updates?.unavailable ?? t('Si installano da terminale.')),
604
746
  });
605
747
  return checks;
606
748
  };
@@ -630,7 +772,7 @@ export function createApp(base) {
630
772
  if ((await auth.countUsers()) > 0)
631
773
  throw new ApiError(409, 'already_set_up', 'An administrator already exists');
632
774
  if (ctx.setupToken && input.token !== ctx.setupToken)
633
- throw new ApiError(403, 'setup_token', 'Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice');
775
+ throw new ApiError(403, 'setup_token', t('Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice'));
634
776
  // The site's language and name, before anything else is written in them.
635
777
  if (input.site?.locale && input.site.locale !== ctx.config().locales.default) {
636
778
  const value = { default: input.site.locale, available: [input.site.locale] };
@@ -683,19 +825,19 @@ export function createApp(base) {
683
825
  const until = await auth.lockedUntil(key);
684
826
  if (until) {
685
827
  const minutes = Math.max(1, Math.ceil((until.getTime() - Date.now()) / 60_000));
686
- throw new ApiError(429, 'too_many_attempts', `Troppi tentativi: riprova tra ${minutes === 1 ? 'un minuto' : `${minutes} minuti`}`);
828
+ throw new ApiError(429, 'too_many_attempts', t.n(minutes, 'Troppi tentativi: riprova tra un minuto', 'Troppi tentativi: riprova tra {n} minuti'));
687
829
  }
688
830
  }
689
831
  };
690
832
  /** A failed sign-in: counted for the email and the address; the owner hears when their account pauses. */
691
- const signInFailed = async (email, ip, hasAccount) => {
833
+ const signInFailed = async (email, ip, hasAccount, locale) => {
692
834
  const byEmail = await auth.recordFailure(`email:${email}`, EMAIL_THRESHOLD);
693
835
  await auth.recordFailure(`ip:${ip}`, IP_THRESHOLD);
694
836
  if (hasAccount && byEmail.failures === EMAIL_THRESHOLD)
695
- await mailNotice(email, 'Tentativi di accesso non riusciti', [
696
- `Qualcuno ha provato ${EMAIL_THRESHOLD} volte di fila a entrare nel pannello del sito con il tuo indirizzo, senza la password giusta. Per sicurezza gli accessi con il tuo indirizzo sono in pausa per qualche minuto.`,
697
- 'Se eri tu, aspetta e riprova. Se non eri tu, la tua password è ancora al sicuro: per più protezione attiva la verifica in due passaggi dal tuo profilo.',
698
- ]);
837
+ await withLang(langFor(locale), () => mailNotice(email, t('Tentativi di accesso non riusciti'), [
838
+ t('Qualcuno ha provato {n} volte di fila a entrare nel pannello del sito con il tuo indirizzo, senza la password giusta. Per sicurezza gli accessi con il tuo indirizzo sono in pausa per qualche minuto.', { n: EMAIL_THRESHOLD }),
839
+ t('Se eri tu, aspetta e riprova. Se non eri tu, la tua password è ancora al sicuro: per più protezione attiva la verifica in due passaggi dal tuo profilo.'),
840
+ ]));
699
841
  };
700
842
  /** Passwords accepted, codes still due: by ticket, in memory for a few minutes. */
701
843
  const challenges = new Map();
@@ -731,7 +873,7 @@ export function createApp(base) {
731
873
  // A password is checked even for an unknown email: the answer takes as long either way.
732
874
  const ok = await verifyPassword(input.password, found?.passwordHash ?? (await dummyHash));
733
875
  if (!found || !ok) {
734
- await signInFailed(email, ip, !!found);
876
+ await signInFailed(email, ip, !!found, found?.locale);
735
877
  await record(c, 'login.failed', email, { known: !!found }, found ? { id: found.id, name: found.name } : null);
736
878
  throw new ApiError(401, 'invalid_credentials', 'Email or password is not correct');
737
879
  }
@@ -754,23 +896,32 @@ export function createApp(base) {
754
896
  await auth.recordFailure(`forgot:${email}`, FORGOT_THRESHOLD);
755
897
  await auth.recordFailure(`forgot-ip:${ip}`, FORGOT_IP_THRESHOLD);
756
898
  const mail = isConfigured(await settings.get('mail'));
757
- const found = mail ? await auth.findForLogin(email) : null;
758
- if (found) {
759
- const { token } = await auth.createInvite({
760
- kind: 'reset',
761
- email: found.email,
762
- name: found.name,
763
- role: found.role,
764
- userId: found.id,
765
- ttlMs: FORGOT_TTL,
766
- });
767
- await mailLink(c, found.email, 'Nuova password', [
768
- `Qualcuno ha chiesto una nuova password per il tuo account su ${ctx.config().site.name}.`,
769
- 'Se non sei stato tu, ignora questa email: la password resta quella di prima.',
770
- 'Per sceglierne una nuova, apri questo link entro un’ora:',
771
- ], linkPath(token));
772
- await record(c, 'password.forgot', found.email, null, { id: found.id, name: found.name });
773
- }
899
+ const origin = strangerOrigin(c);
900
+ if (mail && !origin)
901
+ console.warn('Materialize: no password link sent, because the site address (MATERIALIZE_SITE_URL) is not set');
902
+ // After the answer: how long it takes must not tell whether the email has an account.
903
+ if (mail && origin)
904
+ void (async () => {
905
+ const found = await auth.findForLogin(email);
906
+ if (!found)
907
+ return;
908
+ const { token } = await auth.createInvite({
909
+ kind: 'reset',
910
+ email: found.email,
911
+ name: found.name,
912
+ role: found.role,
913
+ userId: found.id,
914
+ ttlMs: FORGOT_TTL,
915
+ });
916
+ await withLang(langFor(found.locale), () => mailLink(origin, found.email, t('Nuova password'), [
917
+ t('Qualcuno ha chiesto una nuova password per il tuo account su {site}.', {
918
+ site: ctx.config().site.name,
919
+ }),
920
+ t('Se non sei stato tu, ignora questa email: la password resta quella di prima.'),
921
+ t('Per sceglierne una nuova, apri questo link entro un’ora:'),
922
+ ], linkPath(token)));
923
+ await record(c, 'password.forgot', found.email, null, { id: found.id, name: found.name });
924
+ })().catch((error) => console.error('Materialize: password link not sent', error));
774
925
  return c.json({ ok: true, mail });
775
926
  });
776
927
  /** The second step: a code from the app, or a recovery code. */
@@ -827,7 +978,7 @@ export function createApp(base) {
827
978
  const hash = await auth.getPasswordHash(userId);
828
979
  if (!hash || !(await verifyPassword(password, hash)))
829
980
  throw new ApiError(422, 'wrong_password', 'The password is not correct', [
830
- { path, message: 'La password non è corretta' },
981
+ { path, message: t('La password non è corretta') },
831
982
  ]);
832
983
  };
833
984
  app.get('/api/auth/security', requireUser, requireSession, async (c) => {
@@ -859,7 +1010,7 @@ export function createApp(base) {
859
1010
  const step = verifyCode(state.pending, input.code);
860
1011
  if (step === null)
861
1012
  throw new ApiError(422, 'invalid_code', 'The code is not correct', [
862
- { path: 'code', message: 'Il codice non è corretto' },
1013
+ { path: 'code', message: t('Il codice non è corretto') },
863
1014
  ]);
864
1015
  const codes = recoveryCodes();
865
1016
  await auth.enableTwoFactor(user.id, step, codes);
@@ -913,7 +1064,12 @@ export function createApp(base) {
913
1064
  before: before || undefined,
914
1065
  limit: 50,
915
1066
  });
916
- return c.json({ items });
1067
+ // Messages copied into the log (a release or a schedule that failed) are kept texts.
1068
+ return c.json({
1069
+ items: items.map((i) => typeof i.detail?.message === 'string'
1070
+ ? { ...i, detail: { ...i.detail, message: t.read(i.detail.message) } }
1071
+ : i),
1072
+ });
917
1073
  });
918
1074
  /**
919
1075
  * What an administrator should look at, in order: who signs in without a second step, whether
@@ -921,6 +1077,7 @@ export function createApp(base) {
921
1077
  */
922
1078
  app.get('/api/security/overview', requireUser, requireAdmin, async (c) => {
923
1079
  const checks = [];
1080
+ const hoursAgo = (ms) => new Intl.RelativeTimeFormat(intlLocaleOf(currentLang()), { numeric: 'auto' }).format(-Math.round(ms / 3_600_000), 'hour');
924
1081
  const people = await auth.listUsers();
925
1082
  const required = await twoFactorRequired();
926
1083
  const admins = people.filter((p) => p.role === 'admin');
@@ -930,36 +1087,34 @@ export function createApp(base) {
930
1087
  ? {
931
1088
  id: 'admins-2fa',
932
1089
  status: 'bad',
933
- title: adminsWithout.length === 1
934
- ? 'Un amministratore entra con la sola password'
935
- : `${adminsWithout.length} amministratori entrano con la sola password`,
936
- detail: `${adminsWithout.map((p) => p.name).join(', ')}: chi ruba quella password controlla tutto il sito. Attivate la verifica in due passaggi dal profilo → Sicurezza.`,
1090
+ title: t.n(adminsWithout.length, 'Un amministratore entra con la sola password', '{n} amministratori entrano con la sola password'),
1091
+ detail: t('{names}: chi ruba quella password controlla tutto il sito. Attiva la verifica in due passaggi dal profilo → Sicurezza.', { names: adminsWithout.map((p) => p.name).join(', ') }),
937
1092
  action: adminsWithout.some((p) => p.id === c.get('user').id) ? 'profile' : 'people',
938
1093
  }
939
1094
  : {
940
1095
  id: 'admins-2fa',
941
1096
  status: 'ok',
942
- title: 'Gli amministratori usano la verifica in due passaggi',
943
- detail: 'Per entrare servono password e codice dal telefono.',
1097
+ title: t('Gli amministratori usano la verifica in due passaggi'),
1098
+ detail: t('Per entrare servono password e codice dal telefono.'),
944
1099
  });
945
1100
  checks.push(required
946
1101
  ? {
947
1102
  id: 'everyone-2fa',
948
1103
  status: 'ok',
949
- title: 'Verifica in due passaggi richiesta a tutti',
1104
+ title: t('Verifica in due passaggi richiesta a tutti'),
950
1105
  detail: without.length
951
- ? `${without.length === 1 ? 'Una persona la attiverà' : `${without.length} persone la attiveranno`} al prossimo accesso.`
952
- : 'Tutti l’hanno attivata.',
1106
+ ? t.n(without.length, 'Una persona la attiverà al prossimo accesso.', '{n} persone la attiveranno al prossimo accesso.')
1107
+ : t('Tutti l’hanno attivata.'),
953
1108
  }
954
1109
  : {
955
1110
  id: 'everyone-2fa',
956
1111
  status: without.length ? 'warn' : 'ok',
957
1112
  title: without.length
958
- ? `${without.length === 1 ? 'Una persona entra' : `${without.length} persone entrano`} con la sola password`
959
- : 'Tutti usano la verifica in due passaggi',
1113
+ ? t.n(without.length, 'Una persona entra con la sola password', '{n} persone entrano con la sola password')
1114
+ : t('Tutti usano la verifica in due passaggi'),
960
1115
  detail: without.length
961
- ? 'Potete richiederla a tutti da Persone: chi non l’ha la attiverà al prossimo accesso.'
962
- : 'Potete anche richiederla a tutti da Persone, per chi entrerà in futuro.',
1116
+ ? t('Puoi richiederla a tutti da Persone: chi non l’ha la attiverà al prossimo accesso.')
1117
+ : t('Puoi anche richiederla a tutti da Persone, per chi entrerà in futuro.'),
963
1118
  action: without.length ? 'people' : undefined,
964
1119
  });
965
1120
  const mail = isConfigured(await settings.get('mail'));
@@ -967,14 +1122,14 @@ export function createApp(base) {
967
1122
  ? {
968
1123
  id: 'mail',
969
1124
  status: 'ok',
970
- title: 'Gli avvisi arrivano per email',
971
- detail: 'Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.',
1125
+ title: t('Gli avvisi arrivano per email'),
1126
+ detail: t('Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.'),
972
1127
  }
973
1128
  : {
974
1129
  id: 'mail',
975
1130
  status: 'warn',
976
- title: 'L’invio delle email non è configurato',
977
- detail: 'Nessuno viene avvisato di tentativi di accesso falliti o di un indirizzo cambiato, e i link per una nuova password vanno mandati a mano.',
1131
+ title: t('L’invio delle email non è configurato'),
1132
+ detail: t('Nessuno viene avvisato di tentativi di accesso falliti o di un indirizzo cambiato, e i link per una nuova password vanno mandati a mano.'),
978
1133
  action: 'mail',
979
1134
  });
980
1135
  const url = new URL(c.req.url);
@@ -986,92 +1141,108 @@ export function createApp(base) {
986
1141
  ? {
987
1142
  id: 'https',
988
1143
  status: 'info',
989
- title: 'Pannello aperto in locale',
990
- detail: 'Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.',
1144
+ title: t('Pannello aperto in locale'),
1145
+ detail: t('Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.'),
991
1146
  }
992
1147
  : secure
993
1148
  ? {
994
1149
  id: 'https',
995
1150
  status: 'ok',
996
- title: 'Connessione cifrata (HTTPS)',
997
- detail: 'Password e sessioni viaggiano cifrate.',
1151
+ title: t('Connessione cifrata (HTTPS)'),
1152
+ detail: t('Password e sessioni viaggiano cifrate.'),
998
1153
  }
999
1154
  : {
1000
1155
  id: 'https',
1001
1156
  status: 'bad',
1002
- title: 'Il pannello non usa HTTPS',
1003
- detail: 'Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.',
1157
+ title: t('Il pannello non usa HTTPS'),
1158
+ detail: t('Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.'),
1004
1159
  });
1160
+ // Without it, links in emails asked for from the sign-in page cannot be trusted, so none is sent.
1161
+ if (!siteOrigin() && !local)
1162
+ checks.push({
1163
+ id: 'site-url',
1164
+ status: 'warn',
1165
+ title: t('L’indirizzo del sito non è impostato'),
1166
+ detail: t('Senza, “Password dimenticata?” non manda il link: la richiesta può dichiarare un indirizzo falso. Imposta {env} nell’ambiente del server, o site.url in materialize.config.ts.', { env: 'MATERIALIZE_SITE_URL' }),
1167
+ });
1005
1168
  const forwarded = !!c.req.header('x-forwarded-for');
1006
1169
  const trust = ctx.config().server.trustProxy;
1007
1170
  if (forwarded && !trust)
1008
1171
  checks.push({
1009
1172
  id: 'proxy',
1010
1173
  status: 'warn',
1011
- title: 'Il sito è dietro un proxy, ma non lo sa',
1012
- detail: 'Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: { trustProxy: true } in materialize.config.ts.',
1174
+ title: t('Il sito è dietro un proxy, ma non lo sa'),
1175
+ detail: t('Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: {code} in materialize.config.ts.', { code: '{ trustProxy: true }' }),
1013
1176
  });
1014
1177
  else if (trust && !forwarded && !local)
1015
1178
  checks.push({
1016
1179
  id: 'proxy',
1017
1180
  status: 'warn',
1018
- title: 'trustProxy è acceso, ma non arriva nessun proxy',
1019
- detail: 'Senza un proxy davanti, chiunque può dichiarare un indirizzo falso e aggirare i limiti agli accessi. Se il sito non è dietro un proxy, spegnilo in materialize.config.ts.',
1181
+ title: t('trustProxy è acceso, ma non arriva nessun proxy'),
1182
+ detail: t('Senza un proxy davanti, chiunque può dichiarare un indirizzo falso e aggirare i limiti agli accessi. Se il sito non è dietro un proxy, spegnilo in materialize.config.ts.'),
1020
1183
  });
1021
1184
  const day = new Date(Date.now() - 24 * 60 * 60_000);
1022
1185
  const failed = (await activityLog.countSince('login.failed', day)) + (await activityLog.countSince('login.code_failed', day));
1023
1186
  const locked = await activityLog.lockedKeys();
1024
1187
  checks.push({
1025
1188
  id: 'failed',
1026
- status: failed >= 20 || locked.length ? 'warn' : 'ok',
1189
+ // Some failures are people mistyping; many, or a paused account, are someone trying.
1190
+ status: failed >= 20 || locked.length ? 'warn' : failed ? 'info' : 'ok',
1027
1191
  title: failed
1028
- ? `${failed === 1 ? 'Un accesso fallito' : `${failed} accessi falliti`} nelle ultime 24 ore`
1029
- : 'Nessun accesso fallito nelle ultime 24 ore',
1192
+ ? t.n(failed, 'Un accesso fallito nelle ultime 24 ore', '{n} accessi falliti nelle ultime 24 ore')
1193
+ : t('Nessun accesso fallito nelle ultime 24 ore'),
1030
1194
  detail: locked.length
1031
- ? `In pausa adesso: ${locked
1032
- .map((k) => k
1033
- .replace(/^email:/, '')
1034
- .replace(/^ip:/, 'indirizzo ')
1035
- .replace(/^2fa:.*/, 'un codice di verifica'))
1036
- .join(', ')}. Le pause finiscono da sole.`
1037
- : 'Dopo 5 errori un’email va in pausa, dopo 20 un indirizzo: la pausa raddoppia a ogni nuovo errore.',
1195
+ ? t('In pausa adesso: {list}. Le pause finiscono da sole.', {
1196
+ list: locked
1197
+ .map((k) => k.startsWith('ip:')
1198
+ ? t('indirizzo {ip}', { ip: k.slice(3) })
1199
+ : k.startsWith('2fa:')
1200
+ ? t('un codice di verifica')
1201
+ : k.replace(/^email:/, ''))
1202
+ .join(', '),
1203
+ })
1204
+ : t('Dopo 5 errori un’email va in pausa, dopo 20 un indirizzo: la pausa raddoppia a ogni nuovo errore.'),
1038
1205
  });
1039
1206
  if (!backups.available())
1040
1207
  checks.push({
1041
1208
  id: 'backups',
1042
1209
  status: 'info',
1043
- title: 'Database ospitato da un fornitore',
1044
- detail: 'I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.',
1210
+ title: t('Database ospitato da un fornitore'),
1211
+ detail: t('I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.'),
1045
1212
  });
1046
1213
  else {
1047
1214
  const b = await backupSettings();
1048
1215
  const last = (await backups.list())[0];
1049
1216
  const age = last ? Date.now() - last.createdAt.getTime() : Number.POSITIVE_INFINITY;
1217
+ const days = Math.round(age / 86_400_000);
1050
1218
  checks.push(!b.enabled
1051
1219
  ? {
1052
1220
  id: 'backups',
1053
1221
  status: 'warn',
1054
- title: 'Backup automatici spenti',
1222
+ title: t('Backup automatici spenti'),
1055
1223
  detail: last
1056
- ? `L’ultima copia è di ${Math.round(age / 86_400_000)} giorni fa. Riaccendili qui sotto.`
1057
- : 'Non c’è nessuna copia del database. Riaccendili qui sotto.',
1224
+ ? t.n(days, 'L’ultima copia è di un giorno fa. Riaccendili qui sotto.', 'L’ultima copia è di {n} giorni fa. Riaccendili qui sotto.')
1225
+ : t('Non c’è nessuna copia del database. Riaccendili qui sotto.'),
1058
1226
  action: 'backups',
1059
1227
  }
1060
1228
  : age > 2 * 86_400_000
1061
1229
  ? {
1062
1230
  id: 'backups',
1063
1231
  status: last ? 'warn' : 'info',
1064
- title: last ? 'L’ultimo backup è vecchio' : 'Il primo backup arriva a breve',
1232
+ title: last ? t('L’ultimo backup è vecchio') : t('Il primo backup arriva a breve'),
1065
1233
  detail: last
1066
- ? 'Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.'
1067
- : 'Il server fa una copia del database poco dopo l’avvio, poi una al giorno.',
1234
+ ? t('Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.')
1235
+ : t('Il server fa una copia del database poco dopo l’avvio, poi una al giorno.'),
1068
1236
  action: 'backups',
1069
1237
  }
1070
1238
  : {
1071
1239
  id: 'backups',
1072
1240
  status: 'ok',
1073
- title: 'Backup del database ogni giorno',
1074
- detail: `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round(age / 3_600_000), 'hour')}, immagini comprese; ne restano ${b.keep}.`,
1241
+ title: t('Backup del database ogni giorno'),
1242
+ detail: t('Ultima copia {when}, immagini comprese; ne restano {keep}.', {
1243
+ when: hoursAgo(age),
1244
+ keep: b.keep,
1245
+ }),
1075
1246
  });
1076
1247
  }
1077
1248
  {
@@ -1081,25 +1252,28 @@ export function createApp(base) {
1081
1252
  ? {
1082
1253
  id: 'offsite',
1083
1254
  status: 'warn',
1084
- title: 'I backup restano sul server',
1085
- 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.',
1255
+ title: t('I backup restano sul server'),
1256
+ detail: t('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.'),
1086
1257
  action: 'backups',
1087
1258
  }
1088
1259
  : status && !status.ok
1089
1260
  ? {
1090
1261
  id: 'offsite',
1091
1262
  status: 'bad',
1092
- title: 'La copia fuori dal server non riesce',
1093
- detail: status.message ?? 'Controlla il collegamento.',
1263
+ title: t('La copia fuori dal server non riesce'),
1264
+ detail: status.message ?? t('Controlla il collegamento.'),
1094
1265
  action: 'backups',
1095
1266
  }
1096
1267
  : {
1097
1268
  id: 'offsite',
1098
1269
  status: 'ok',
1099
- title: 'Copia cifrata fuori dal server',
1270
+ title: t('Copia cifrata fuori dal server'),
1100
1271
  detail: status
1101
- ? `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'}.`
1102
- : 'La prima copia parte entro un’ora.',
1272
+ ? t('Ultima copia {when}, su {where}.', {
1273
+ when: hoursAgo(Date.now() - new Date(status.at).getTime()),
1274
+ where: o.kind === 's3' ? 'S3' : 'WebDAV',
1275
+ })
1276
+ : t('La prima copia parte entro un’ora.'),
1103
1277
  });
1104
1278
  }
1105
1279
  const tokens = await activityLog.tokens();
@@ -1108,10 +1282,10 @@ export function createApp(base) {
1108
1282
  checks.push({
1109
1283
  id: 'tokens',
1110
1284
  status: stale.length ? 'warn' : 'ok',
1111
- title: `${tokens.length === 1 ? 'Un token di accesso' : `${tokens.length} token di accesso`} per i client AI`,
1285
+ title: t.n(tokens.length, 'Un token di accesso per i client AI', '{n} token di accesso per i client AI'),
1112
1286
  detail: stale.length
1113
- ? `${stale.length === 1 ? 'Uno non è usato' : `${stale.length} non sono usati`} da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.`
1114
- : 'Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.',
1287
+ ? t.n(stale.length, 'Uno non è usato da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.', '{n} non sono usati da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.')
1288
+ : t('Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.'),
1115
1289
  });
1116
1290
  return c.json({ checks });
1117
1291
  });
@@ -1200,21 +1374,30 @@ export function createApp(base) {
1200
1374
  s3: s3Input.optional(),
1201
1375
  webdav: webdavInput.optional(),
1202
1376
  });
1203
- /** The settings as sent, with the saved secrets where the panel left them empty. */
1377
+ /**
1378
+ * The settings as sent, with the saved secrets where the panel left them empty — only for the
1379
+ * same server: a secret is never sent to an address it was not given for (a stolen session
1380
+ * could otherwise point the saved key to its own server and read it there).
1381
+ */
1204
1382
  const withSecrets = (input, saved) => {
1205
1383
  if (input.kind === 's3' && !input.s3)
1206
1384
  throw new ApiError(400, 'missing_s3', 'S3 settings are missing');
1207
1385
  if (input.kind === 'webdav' && !input.webdav)
1208
1386
  throw new ApiError(400, 'missing_webdav', 'WebDAV settings are missing');
1387
+ const sameS3 = !!input.s3 && sameServer(input.s3.endpoint, saved.s3?.endpoint);
1388
+ const sameDav = !!input.webdav && sameServer(input.webdav.url, saved.webdav?.url);
1209
1389
  return {
1210
1390
  kind: input.kind,
1211
1391
  keep: input.keep,
1212
1392
  key: saved.key,
1213
1393
  s3: input.s3
1214
- ? { ...input.s3, secretAccessKey: input.s3.secretAccessKey || saved.s3?.secretAccessKey || '' }
1394
+ ? {
1395
+ ...input.s3,
1396
+ secretAccessKey: input.s3.secretAccessKey || (sameS3 ? saved.s3?.secretAccessKey : '') || '',
1397
+ }
1215
1398
  : saved.s3,
1216
1399
  webdav: input.webdav
1217
- ? { ...input.webdav, password: input.webdav.password || saved.webdav?.password || '' }
1400
+ ? { ...input.webdav, password: input.webdav.password || (sameDav ? saved.webdav?.password : '') || '' }
1218
1401
  : saved.webdav,
1219
1402
  };
1220
1403
  };
@@ -1295,7 +1478,7 @@ export function createApp(base) {
1295
1478
  latest: null,
1296
1479
  notes: [],
1297
1480
  checkedAt: null,
1298
- unavailable: 'Questo server non è avviato da materialize start: aggiorna da terminale.',
1481
+ unavailable: t('Questo server non è avviato da materialize start: aggiorna da terminale.'),
1299
1482
  previous: null,
1300
1483
  job: { state: 'idle' },
1301
1484
  last: null,
@@ -1310,7 +1493,7 @@ export function createApp(base) {
1310
1493
  app.post('/api/updates/install', requireUser, requireSession, requireAdmin, async (c) => {
1311
1494
  const input = await body(c, z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }));
1312
1495
  if (!ctx.updates)
1313
- throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
1496
+ throw new ApiError(409, 'updates_unavailable', t('Gli aggiornamenti dal pannello non sono disponibili qui'));
1314
1497
  try {
1315
1498
  await ctx.updates.install(input.version, backupBeforeUpdate);
1316
1499
  }
@@ -1322,7 +1505,7 @@ export function createApp(base) {
1322
1505
  });
1323
1506
  app.post('/api/updates/rollback', requireUser, requireSession, requireAdmin, async (c) => {
1324
1507
  if (!ctx.updates)
1325
- throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
1508
+ throw new ApiError(409, 'updates_unavailable', t('Gli aggiornamenti dal pannello non sono disponibili qui'));
1326
1509
  try {
1327
1510
  await ctx.updates.rollback();
1328
1511
  }
@@ -1369,11 +1552,17 @@ export function createApp(base) {
1369
1552
  return c.json({ user: await auth.updateUser(c.get('user').id, input) });
1370
1553
  });
1371
1554
  /** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
1372
- const emailChanged = async (before, after, by) => {
1373
- await mailNotice(before, 'Il tuo indirizzo email è cambiato', [
1374
- `${by === 'self' ? 'Hai cambiato' : `${by} ha cambiato`} l’indirizzo con cui entri nel pannello del sito: da ${before} a ${after}.`,
1375
- 'Se non te lo aspettavi, rispondi a questa email o avvisa un amministratore del sito.',
1376
- ]);
1555
+ const emailChanged = async (before, after, by, locale) => {
1556
+ await withLang(langFor(locale), () => mailNotice(before, t('Il tuo indirizzo email è cambiato'), [
1557
+ by === 'self'
1558
+ ? t('Hai cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.', { before, after })
1559
+ : t('{by} ha cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.', {
1560
+ by,
1561
+ before,
1562
+ after,
1563
+ }),
1564
+ t('Se non te lo aspettavi, rispondi a questa email o avvisa un amministratore del sito.'),
1565
+ ]));
1377
1566
  };
1378
1567
  // Changing one's own email needs the password: a session left open is not enough.
1379
1568
  app.post('/api/auth/email', requireUser, requireSession, async (c) => {
@@ -1382,13 +1571,13 @@ export function createApp(base) {
1382
1571
  const hash = await auth.getPasswordHash(user.id);
1383
1572
  if (!hash || !(await verifyPassword(input.password, hash))) {
1384
1573
  throw new ApiError(422, 'wrong_password', 'The password is not correct', [
1385
- { path: 'password', message: 'La password non è corretta' },
1574
+ { path: 'password', message: t('La password non è corretta') },
1386
1575
  ]);
1387
1576
  }
1388
1577
  try {
1389
1578
  const updated = await auth.updateUser(user.id, { email: input.email });
1390
1579
  if (updated && updated.email !== user.email) {
1391
- await emailChanged(user.email, updated.email, 'self');
1580
+ await emailChanged(user.email, updated.email, 'self', user.locale);
1392
1581
  await record(c, 'user.email', updated.email, { from: user.email });
1393
1582
  }
1394
1583
  return c.json({ user: updated });
@@ -1400,12 +1589,12 @@ export function createApp(base) {
1400
1589
  }
1401
1590
  });
1402
1591
  app.post('/api/auth/password', requireUser, requireSession, async (c) => {
1403
- const input = await body(c, z.object({ current: z.string().min(1), next: z.string().min(10) }));
1592
+ const input = await body(c, z.object({ current: z.string().min(1).max(MAX_PASSWORD), next: z.string().min(10).max(MAX_PASSWORD) }));
1404
1593
  const user = c.get('user');
1405
1594
  const hash = await auth.getPasswordHash(user.id);
1406
1595
  if (!hash || !(await verifyPassword(input.current, hash))) {
1407
1596
  throw new ApiError(422, 'wrong_password', 'The current password is not correct', [
1408
- { path: 'current', message: 'La password attuale non è corretta' },
1597
+ { path: 'current', message: t('La password attuale non è corretta') },
1409
1598
  ]);
1410
1599
  }
1411
1600
  // Other devices are signed out; this one stays in.
@@ -1431,9 +1620,9 @@ export function createApp(base) {
1431
1620
  ttlMs: INVITE_TTL,
1432
1621
  });
1433
1622
  const link = linkPath(token);
1434
- const mailed = await mailLink(c, input.email, 'Il tuo invito', [
1435
- `${c.get('user').name} ti ha invitato a lavorare sul sito con Materialize.`,
1436
- 'Apri questo link per scegliere la tua password ed entrare:',
1623
+ const mailed = await mailLink(publicOrigin(c), input.email, t('Il tuo invito'), [
1624
+ t('{name} ti ha invitato a lavorare sul sito con Materialize.', { name: c.get('user').name }),
1625
+ t('Apri questo link per scegliere la tua password ed entrare:'),
1437
1626
  ], link);
1438
1627
  await record(c, 'user.invited', input.email, { role: input.role });
1439
1628
  return c.json({ invite, link, mailed }, 201);
@@ -1463,7 +1652,7 @@ export function createApp(base) {
1463
1652
  try {
1464
1653
  const updated = await auth.updateUser(target.id, input);
1465
1654
  if (updated && updated.email !== target.email) {
1466
- await emailChanged(target.email, updated.email, c.get('user').name);
1655
+ await emailChanged(target.email, updated.email, c.get('user').name, target.locale);
1467
1656
  await record(c, 'user.email', updated.email, { from: target.email });
1468
1657
  }
1469
1658
  if (updated && updated.role !== target.role)
@@ -1491,10 +1680,10 @@ export function createApp(base) {
1491
1680
  app.delete('/api/users/:id/two-factor', requireUser, requireSession, requireAdmin, async (c) => {
1492
1681
  const target = await loadUser(c.req.param('id'));
1493
1682
  await auth.disableTwoFactor(target.id);
1494
- await mailNotice(target.email, 'Verifica in due passaggi disattivata', [
1495
- `${c.get('user').name} ha disattivato la verifica in due passaggi del tuo account: per entrare nel pannello ora basta la password.`,
1496
- 'Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.',
1497
- ]);
1683
+ await withLang(langFor(target.locale), () => mailNotice(target.email, t('Verifica in due passaggi disattivata'), [
1684
+ t('{name} ha disattivato la verifica in due passaggi del tuo account: per entrare nel pannello ora basta la password.', { name: c.get('user').name }),
1685
+ t('Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.'),
1686
+ ]));
1498
1687
  await record(c, 'twofactor.reset', target.email);
1499
1688
  return c.json({ user: await auth.getUser(target.id) });
1500
1689
  });
@@ -1510,7 +1699,7 @@ export function createApp(base) {
1510
1699
  ttlMs: RESET_TTL,
1511
1700
  });
1512
1701
  const link = linkPath(token);
1513
- const mailed = await mailLink(c, target.email, 'Nuova password', ['È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:'], link);
1702
+ const mailed = await withLang(langFor(target.locale), () => mailLink(publicOrigin(c), target.email, t('Nuova password'), [t('È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:')], link));
1514
1703
  await record(c, 'user.reset_link', target.email);
1515
1704
  return c.json({ invite, link, mailed }, 201);
1516
1705
  });
@@ -1528,11 +1717,14 @@ export function createApp(base) {
1528
1717
  app.post('/api/invites/:token/accept', async (c) => {
1529
1718
  const token = c.req.param('token');
1530
1719
  const invite = await loadInvite(token);
1531
- const input = await body(c, z.object({ name: z.string().trim().min(1).max(80).optional(), password: z.string().min(10) }));
1720
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(80).optional(), password: z.string().min(10).max(MAX_PASSWORD) }));
1532
1721
  const passwordHash = await hashPassword(input.password);
1533
1722
  let user;
1534
1723
  if (invite.kind === 'reset' && invite.userId) {
1724
+ // Signed out everywhere (setPassword) and its tokens revoked: a reset is how an account
1725
+ // someone else got into is won back, and what they made with it must stop working too.
1535
1726
  await auth.setPassword(invite.userId, passwordHash);
1727
+ await auth.revokeAllTokens(invite.userId);
1536
1728
  user = await auth.getUser(invite.userId);
1537
1729
  }
1538
1730
  else {
@@ -1639,7 +1831,9 @@ export function createApp(base) {
1639
1831
  const usage = await globalUsage();
1640
1832
  const inUse = removed.find((id) => usage[id]?.length);
1641
1833
  if (inUse)
1642
- throw new ApiError(409, 'global_in_use', `La sezione è ancora in ${usage[inUse]?.map((t) => `“${t}”`).join(', ')}: toglila prima dalle pagine`);
1834
+ throw new ApiError(409, 'global_in_use', t('La sezione è ancora in {pages}: toglila prima dalle pagine', {
1835
+ pages: usage[inUse]?.map((title) => `“${title}”`).join(', '),
1836
+ }));
1643
1837
  }
1644
1838
  const ids = [...new Set([...Object.keys(before), ...Object.keys(input.globals)])].sort();
1645
1839
  await guardDesign(c.get('user'), ids.map((id) => before[id] ?? { id, type: 'frame' }), ids.map((id) => input.globals[id] ?? { id, type: 'frame' }));
@@ -1704,7 +1898,8 @@ export function createApp(base) {
1704
1898
  if (input.take && turn && turn.tab !== input.tab) {
1705
1899
  // The one who had it learns who took it, at their next beat.
1706
1900
  turns.set(`lost:${docId}:${turn.tab}`, { ...turn, takenBy: user.name, at: now });
1707
- await record(c, 'document.turn_taken', '', { id: docId, from: turn.name });
1901
+ const doc = await content.get(docId);
1902
+ await record(c, 'document.turn_taken', doc?.title ?? '', { id: docId, from: turn.name });
1708
1903
  }
1709
1904
  const keep = turn && turn.userId === user.id && turn.tab === input.tab;
1710
1905
  turns.set(docId, { userId: user.id, name: user.name, tab: input.tab, since: keep ? turn.since : now, at: now });
@@ -1734,9 +1929,9 @@ export function createApp(base) {
1734
1929
  const user = c.get('user');
1735
1930
  const tab = c.req.header('x-mz-tab');
1736
1931
  if (turn.userId !== user.id)
1737
- throw new ApiError(409, 'not_your_turn', `${turn.name} sta modificando questo documento: le tue modifiche non sono state salvate`);
1932
+ throw new ApiError(409, 'not_your_turn', t('{name} sta modificando questo documento: le tue modifiche non sono state salvate', { name: turn.name }));
1738
1933
  if (tab && tab !== turn.tab)
1739
- throw new ApiError(409, 'not_your_turn', 'Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate');
1934
+ throw new ApiError(409, 'not_your_turn', t('Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate'));
1740
1935
  };
1741
1936
  app.post('/api/documents/:id/presence', requireUser, async (c) => {
1742
1937
  const input = await body(c, z.object({ selected: z.string().max(200).nullable() }));
@@ -1767,26 +1962,37 @@ export function createApp(base) {
1767
1962
  return false;
1768
1963
  }
1769
1964
  };
1770
- /**
1771
- * The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
1772
- * is known. The request's Host header is used only without it, as in local development: anyone
1773
- * can send a request with a false Host, and a link to their server would carry the token there.
1774
- */
1775
- const publicOrigin = (c) => {
1965
+ /** The site's own address (MATERIALIZE_SITE_URL, site.url), when it is set. */
1966
+ const siteOrigin = () => {
1776
1967
  const site = ctx.config().site.url;
1777
1968
  try {
1778
1969
  if (site)
1779
1970
  return new URL(site).origin;
1780
1971
  }
1781
1972
  catch { }
1782
- return new URL(c.req.url).origin;
1973
+ return null;
1783
1974
  };
1784
- const mailLink = async (c, to, subject, lines, path) => {
1975
+ /**
1976
+ * The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
1977
+ * is known. The request's Host header is used only without it, as in local development: anyone
1978
+ * can send a request with a false Host, and a link to their server would carry the token there.
1979
+ */
1980
+ const publicOrigin = (c) => siteOrigin() ?? new URL(c.req.url).origin;
1981
+ /**
1982
+ * The address for links in emails asked for by someone not signed in (a forgotten password):
1983
+ * never the Host header, which they choose, except on this computer during development. Null
1984
+ * when the site's address is not set: then no link is sent (Impostazioni → Sicurezza says so).
1985
+ */
1986
+ const strangerOrigin = (c) => {
1987
+ const url = new URL(c.req.url);
1988
+ return siteOrigin() ?? (isLocalHost(url.hostname) ? url.origin : null);
1989
+ };
1990
+ const mailLink = async (origin, to, subject, lines, path) => {
1785
1991
  const mail = await settings.get('mail');
1786
1992
  if (!isConfigured(mail))
1787
1993
  return false;
1788
1994
  try {
1789
- const url = new URL(path, publicOrigin(c)).toString();
1995
+ const url = new URL(path, origin).toString();
1790
1996
  await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
1791
1997
  return true;
1792
1998
  }
@@ -1811,7 +2017,9 @@ export function createApp(base) {
1811
2017
  }),
1812
2018
  }));
1813
2019
  const before = await settings.get('mail');
1814
- const pass = input.mail.pass === null ? '' : input.mail.pass || before?.pass || '';
2020
+ // The saved password goes only to the server it was given for (see withSecrets).
2021
+ const sameHost = !!before && before.host.trim().toLowerCase() === input.mail.host.trim().toLowerCase();
2022
+ const pass = input.mail.pass === null ? '' : input.mail.pass || (sameHost ? before?.pass : '') || '';
1815
2023
  const mail = { ...input.mail, pass };
1816
2024
  await settings.set('mail', mail, c.get('user').id);
1817
2025
  await record(c, 'settings.mail');
@@ -1825,8 +2033,8 @@ export function createApp(base) {
1825
2033
  try {
1826
2034
  await sendMail(mail, {
1827
2035
  to,
1828
- subject: 'Prova di invio da Materialize',
1829
- text: 'Se leggi questo messaggio, l’invio delle email dal sito funziona.',
2036
+ subject: t('Prova di invio da Materialize'),
2037
+ text: t('Se leggi questo messaggio, l’invio delle email dal sito funziona.'),
1830
2038
  });
1831
2039
  }
1832
2040
  catch (error) {
@@ -1867,7 +2075,7 @@ export function createApp(base) {
1867
2075
  const readyAi = async () => {
1868
2076
  const ai = await aiSettings();
1869
2077
  if (!isReady(ai))
1870
- throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
2078
+ throw new ApiError(400, 'ai_not_configured', t('L’AI non è configurata: Impostazioni → AI e MCP'));
1871
2079
  return { ...ai, onUsage: countUsage };
1872
2080
  };
1873
2081
  const aiFailed = (error) => {
@@ -1892,7 +2100,9 @@ export function createApp(base) {
1892
2100
  }),
1893
2101
  }));
1894
2102
  const before = await aiSettings();
1895
- const apiKey = input.ai.apiKey === null ? '' : input.ai.apiKey?.trim() || before.apiKey;
2103
+ // The saved key goes only to the service it was given for (see withSecrets).
2104
+ const sameService = before.provider === input.ai.provider && sameServer(before.baseUrl, input.ai.baseUrl);
2105
+ const apiKey = input.ai.apiKey === null ? '' : input.ai.apiKey?.trim() || (sameService ? before.apiKey : '');
1896
2106
  const ai = { ...input.ai, apiKey };
1897
2107
  await settings.set('ai', ai, c.get('user').id);
1898
2108
  await record(c, 'settings.ai', ai.provider);
@@ -1931,7 +2141,7 @@ export function createApp(base) {
1931
2141
  */
1932
2142
  app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
1933
2143
  if (!allowAi(c.get('user').id))
1934
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2144
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
1935
2145
  const ai = await readyAi();
1936
2146
  const config = ctx.config();
1937
2147
  const texts = [];
@@ -1948,7 +2158,7 @@ export function createApp(base) {
1948
2158
  }
1949
2159
  }
1950
2160
  if (size < 300)
1951
- throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
2161
+ throw new ApiError(400, 'too_little_text', t('Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano'));
1952
2162
  const reply = await complete(ai, {
1953
2163
  system: 'You describe the voice of a brand from the texts of its website, for a style guide the brand will reuse. Reply with JSON only, no code fences.',
1954
2164
  prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
@@ -1978,7 +2188,7 @@ ${texts.join('\n---\n')}
1978
2188
  address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
1979
2189
  });
1980
2190
  if (!parsed.success)
1981
- throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
2191
+ throw new ApiError(422, 'ai_failed', t('L’AI non ha risposto come doveva: riprova'));
1982
2192
  return c.json({ voice: parsed.data, texts: texts.length });
1983
2193
  });
1984
2194
  // ---------- AI review before publishing ----------
@@ -2008,12 +2218,12 @@ ${texts.join('\n---\n')}
2008
2218
  const existing = await loadDocument(c.req.param('id'));
2009
2219
  const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
2010
2220
  if (!allowAi(c.get('user').id))
2011
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2221
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
2012
2222
  const ai = await readyAi();
2013
2223
  const def = collection(existing.collection);
2014
2224
  const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
2015
2225
  if (!slots.length)
2016
- throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
2226
+ throw new ApiError(400, 'empty_text', t('Non c’è testo da rivedere'));
2017
2227
  const titles = await pageTitles();
2018
2228
  const linkTo = (href) => {
2019
2229
  const path = href.split('#')[0] || href;
@@ -2053,7 +2263,7 @@ ${listed.join('\n')}
2053
2263
  raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
2054
2264
  }
2055
2265
  catch {
2056
- throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
2266
+ throw new ApiError(422, 'ai_failed', t('L’AI non ha risposto come doveva: riprova'));
2057
2267
  }
2058
2268
  const issue = z.object({
2059
2269
  slot: z.number().int().min(0),
@@ -2099,7 +2309,7 @@ ${listed.join('\n')}
2099
2309
  ? slots[input.slot]
2100
2310
  : slots.find((s) => s.text.includes(input.find));
2101
2311
  if (!slot)
2102
- throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
2312
+ throw new ApiError(409, 'changed', t('Quel testo è cambiato: rifai la revisione'));
2103
2313
  slot.set(slot.text.replace(input.find, input.replace));
2104
2314
  return c.json({ data });
2105
2315
  });
@@ -2129,7 +2339,7 @@ ${listed.join('\n')}
2129
2339
  max: z.number().int().positive().max(100_000).optional(),
2130
2340
  }));
2131
2341
  if (!allowAi(c.get('user').id))
2132
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2342
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
2133
2343
  const ai = await readyAi();
2134
2344
  // Writing from scratch takes the whole voice; changing a text, only its style.
2135
2345
  const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
@@ -2141,7 +2351,7 @@ ${listed.join('\n')}
2141
2351
  const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
2142
2352
  const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
2143
2353
  if (!file)
2144
- throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
2354
+ throw new ApiError(400, 'no_image', t('Scegli prima un’immagine della libreria'));
2145
2355
  const data = (await readFile(file.path)).toString('base64');
2146
2356
  // The text already there may credit the photographer or the source: that part stays.
2147
2357
  const current = input.text.trim()
@@ -2156,7 +2366,7 @@ ${listed.join('\n')}
2156
2366
  return c.json({ text });
2157
2367
  }
2158
2368
  if (!input.text.trim())
2159
- throw new ApiError(400, 'empty_text', 'Non c’è testo su cui lavorare');
2369
+ throw new ApiError(400, 'empty_text', t('Non c’è testo su cui lavorare'));
2160
2370
  const task = input.action === 'translate'
2161
2371
  ? `Translate it into ${input.instruction || 'English'}, naturally, as a native writer would.`
2162
2372
  : input.action === 'custom'
@@ -2220,7 +2430,7 @@ ${listed.join('\n')}
2220
2430
  }));
2221
2431
  const user = c.get('user');
2222
2432
  if (!allowAi(user.id))
2223
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2433
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
2224
2434
  const ai = await readyAi();
2225
2435
  const now = Date.now();
2226
2436
  for (const [k, v] of chats)
@@ -2272,7 +2482,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2272
2482
  if (limited && !sameDesign(before, page.nodes))
2273
2483
  await send({
2274
2484
  type: 'error',
2275
- message: 'Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.',
2485
+ message: t('Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.'),
2276
2486
  });
2277
2487
  else
2278
2488
  await send({ type: 'page', nodes: page.nodes });
@@ -2285,7 +2495,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2285
2495
  console.error(error);
2286
2496
  // A failed turn leaves the conversation as it was before it.
2287
2497
  chats.delete(key);
2288
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2498
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2289
2499
  await send({ type: 'done', chatId });
2290
2500
  }
2291
2501
  });
@@ -2297,7 +2507,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2297
2507
  app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
2298
2508
  const input = await body(c, z
2299
2509
  .object({
2300
- brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
2510
+ brief: z.string().trim().min(20, t('Scrivi qualche riga in più sull’attività')).max(6000).optional(),
2301
2511
  /** A reply in a run already started, with its id. */
2302
2512
  runId: z.string().max(64).optional(),
2303
2513
  message: z.string().trim().min(1).max(4000).optional(),
@@ -2310,10 +2520,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2310
2520
  .max(2)
2311
2521
  .default([]),
2312
2522
  })
2313
- .refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
2523
+ .refine((v) => (v.runId ? !!v.message : !!v.brief), { message: t('Scrivi il brief') }));
2314
2524
  const user = c.get('user');
2315
2525
  if (!allowAi(user.id))
2316
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2526
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
2317
2527
  const ai = await readyAi();
2318
2528
  const now = Date.now();
2319
2529
  for (const [k, v] of briefRuns)
@@ -2323,12 +2533,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2323
2533
  const key = `${user.id}:${runId}`;
2324
2534
  let run = input.runId ? briefRuns.get(key) : undefined;
2325
2535
  if (input.runId && (!run || run.conv.provider !== ai.provider))
2326
- throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
2536
+ throw new ApiError(409, 'run_expired', t('Questa conversazione non c’è più: parti da un nuovo brief'));
2327
2537
  if (!run) {
2328
2538
  const config = ctx.config();
2329
2539
  const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
2330
2540
  if (!pageCollections.length)
2331
- throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
2541
+ throw new ApiError(400, 'no_pages', t('Il sito non ha una raccolta di pagine costruite sulla tela'));
2332
2542
  const locale = config.locales.default;
2333
2543
  const existing = [];
2334
2544
  for (const d of config.collections.filter((x) => !x.singleton)) {
@@ -2398,7 +2608,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2398
2608
  // What was made before the failure stays, as drafts: the panel lists it. The run ends.
2399
2609
  briefRuns.delete(key);
2400
2610
  await send({ type: 'result', ...result });
2401
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2611
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2402
2612
  await send({ type: 'done' });
2403
2613
  }
2404
2614
  });
@@ -2419,7 +2629,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2419
2629
  }));
2420
2630
  const user = c.get('user');
2421
2631
  if (!allowAi(user.id))
2422
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2632
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
2423
2633
  const ai = await readyAi();
2424
2634
  const now = Date.now();
2425
2635
  for (const [k, v] of studioRuns)
@@ -2474,7 +2684,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2474
2684
  if (!(error instanceof AiError))
2475
2685
  console.error(error);
2476
2686
  studioRuns.delete(key);
2477
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2687
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2478
2688
  await send({ type: 'done' });
2479
2689
  }
2480
2690
  });
@@ -2531,8 +2741,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2531
2741
  }), async (c) => {
2532
2742
  const form = c.req.param('form').slice(0, 80);
2533
2743
  const ip = clientIp(c);
2534
- if (!allowForm(`${ip}:${form}`))
2535
- throw new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
2744
+ const tooMany = () => new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
2745
+ if (!allowFormIp(ip) || !allowFormIpHour(ip) || !allowForm(`${ip}:${form}`))
2746
+ throw tooMany();
2536
2747
  const type = c.req.header('content-type') ?? '';
2537
2748
  const isJson = type.includes('application/json');
2538
2749
  const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
@@ -2543,6 +2754,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2543
2754
  if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
2544
2755
  return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2545
2756
  const def = (await publishedForms()).get(form) ?? null;
2757
+ if (!def && !allowHandForm(ip))
2758
+ throw tooMany();
2546
2759
  const { data, files, issues } = await readSubmission(raw, def);
2547
2760
  if (issues.length) {
2548
2761
  if (!wantsJson)
@@ -2554,7 +2767,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2554
2767
  const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
2555
2768
  const fingerprint = fingerprintOf(form, data);
2556
2769
  const spam = spamReason(data, elapsed) ??
2557
- ((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
2770
+ ((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? t.keep('Messaggio ripetuto') : null);
2558
2771
  // A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
2559
2772
  if (def?.newsletter) {
2560
2773
  if (!spam)
@@ -2563,7 +2776,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2563
2776
  }
2564
2777
  const formName = def?.name?.trim() || def?.subject?.trim() || '';
2565
2778
  const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2566
- if (files.length)
2779
+ // Spam keeps its words, for a check, but not its files: they would only fill the disk.
2780
+ if (files.length && !spam)
2567
2781
  await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
2568
2782
  // Spam stays in the panel, recoverable, but nobody is notified.
2569
2783
  if (!spam) {
@@ -2572,13 +2786,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2572
2786
  const sender = Object.entries(data).find(([k, v]) => (def?.fields.find((f) => f.name === k)?.kind === 'email' || /mail/i.test(k)) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
2573
2787
  if (isConfigured(mail) && to) {
2574
2788
  const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
2575
- const attached = files.length
2576
- ? `\n\nAllegati (${files.map((f) => f.name).join(', ')}): li trovi in Impostazioni → Messaggi.`
2577
- : '';
2789
+ // The visitor's request says nothing about who reads this: it goes in the owner's language.
2790
+ const notice = withLang(await ownerLang(to), () => ({
2791
+ subject: subject ? t('Nuovo messaggio dal sito: {subject}', { subject }) : t('Nuovo messaggio dal sito'),
2792
+ text: submissionText(page, data) +
2793
+ (files.length
2794
+ ? `\n\n${t('Allegati ({files}): li trovi in Impostazioni → Messaggi.', { files: files.map((f) => f.name).join(', ') })}`
2795
+ : ''),
2796
+ }));
2578
2797
  sendMail(mail, {
2579
2798
  to,
2580
- subject: `Nuovo messaggio dal sito${subject ? `: ${subject}` : ''}`,
2581
- text: submissionText(page, data) + attached,
2799
+ ...notice,
2582
2800
  replyTo: sender,
2583
2801
  })
2584
2802
  .then(() => submissions.markMailed(saved.id))
@@ -2602,7 +2820,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2602
2820
  createdAt: saved.createdAt.toISOString(),
2603
2821
  };
2604
2822
  void webhookSecret()
2605
- .then((secret) => sendWebhook(def.webhook, payload, secret))
2823
+ .then((secret) => sendWebhook(def.webhook, payload, secret, undefined, privateWebhooks))
2606
2824
  .then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
2607
2825
  .catch((error) => console.error('Materialize: webhook not sent', error));
2608
2826
  }
@@ -2618,8 +2836,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2618
2836
  app.get('/api/submissions', requireUser, async (c) => {
2619
2837
  const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
2620
2838
  const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
2839
+ const list = await submissions.list(limit, offset, filterOf(c));
2621
2840
  return c.json({
2622
- ...(await submissions.list(limit, offset, filterOf(c))),
2841
+ ...list,
2842
+ // The spam reason is saved as a kept text: read in the language of whoever looks.
2843
+ items: list.items.map((m) => ({ ...m, spam: m.spam && t.read(m.spam) })),
2623
2844
  forms: await submissions.forms(),
2624
2845
  });
2625
2846
  });
@@ -2633,7 +2854,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2633
2854
  return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
2634
2855
  };
2635
2856
  const rows = [
2636
- ['Data', 'Modulo', 'Pagina', ...fields],
2857
+ [t('Data'), t('Modulo'), t('Pagina'), ...fields],
2637
2858
  ...items.map((m) => [
2638
2859
  m.createdAt.toISOString(),
2639
2860
  m.formName || m.form,
@@ -2657,7 +2878,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2657
2878
  if (input.read !== undefined)
2658
2879
  await submissions.markRead(id, input.read);
2659
2880
  if (input.spam !== undefined)
2660
- await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
2881
+ await submissions.markSpam(id, input.spam ? t.keep('Segnato a mano') : null);
2661
2882
  return c.json({ ok: true });
2662
2883
  });
2663
2884
  /** A file sent with a message: always downloaded, never opened in the panel's own page. */
@@ -2665,10 +2886,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2665
2886
  const message = await submissions.get(c.req.param('id'));
2666
2887
  const file = message?.files[Number(c.req.param('n'))];
2667
2888
  if (!message || !file)
2668
- throw new ApiError(404, 'not_found', 'File non trovato');
2889
+ throw new ApiError(404, 'not_found', t('File non trovato'));
2669
2890
  const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
2670
2891
  if (!bytes)
2671
- throw new ApiError(404, 'not_found', 'Il file non c’è più');
2892
+ throw new ApiError(404, 'not_found', t('Il file non c’è più'));
2672
2893
  return new Response(bytes, {
2673
2894
  headers: {
2674
2895
  'content-type': 'application/octet-stream',
@@ -2736,14 +2957,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2736
2957
  const category = z.enum(['necessary', 'analytics', 'marketing']);
2737
2958
  const input = await body(c, z.object({
2738
2959
  tracking: z.object({
2739
- ga4: id(TRACKING_PATTERNS.ga4, 'Un ID di Google Analytics 4 inizia con G-'),
2740
- gtm: id(TRACKING_PATTERNS.gtm, 'Un ID di Tag Manager inizia con GTM-'),
2960
+ ga4: id(TRACKING_PATTERNS.ga4, t('Un ID di Google Analytics 4 inizia con G-')),
2961
+ gtm: id(TRACKING_PATTERNS.gtm, t('Un ID di Tag Manager inizia con GTM-')),
2741
2962
  gtmCategory: category.optional(),
2742
- metaPixel: id(TRACKING_PATTERNS.metaPixel, 'L’ID del Pixel è fatto di sole cifre'),
2743
- plausible: id(TRACKING_PATTERNS.plausible, 'Scrivi il dominio come su Plausible, per esempio example.com'),
2744
- plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, 'Un indirizzo https://'),
2745
- matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, 'L’indirizzo di Matomo, con https://'),
2746
- matomoSiteId: id(TRACKING_PATTERNS.matomoSiteId, 'Il numero del sito su Matomo'),
2963
+ metaPixel: id(TRACKING_PATTERNS.metaPixel, t('L’ID del Pixel è fatto di sole cifre')),
2964
+ plausible: id(TRACKING_PATTERNS.plausible, t('Scrivi il dominio come su Plausible, per esempio example.com')),
2965
+ plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, t('Un indirizzo https://')),
2966
+ matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, t('L’indirizzo di Matomo, con https://')),
2967
+ matomoSiteId: id(TRACKING_PATTERNS.matomoSiteId, t('Il numero del sito su Matomo')),
2747
2968
  custom: z
2748
2969
  .array(z.object({
2749
2970
  id: z.string().min(1).max(40),
@@ -2753,7 +2974,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2753
2974
  code: z
2754
2975
  .string()
2755
2976
  .max(20_000)
2756
- .refine((v) => !/<\/template/i.test(v), 'Il codice non può contenere </template>'),
2977
+ .refine((v) => !/<\/template/i.test(v), t('Il codice non può contenere </template>')),
2757
2978
  }))
2758
2979
  .max(30)
2759
2980
  .optional(),
@@ -2761,7 +2982,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2761
2982
  .object({
2762
2983
  title: z.string().trim().max(120).optional(),
2763
2984
  text: z.string().trim().max(600).optional(),
2764
- policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, 'Un indirizzo che inizia con / o https://'),
2985
+ policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, t('Un indirizzo che inizia con / o https://')),
2765
2986
  position: z.enum(['bottom', 'corner']).optional(),
2766
2987
  reopenButton: z.boolean().optional(),
2767
2988
  })
@@ -3023,18 +3244,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3023
3244
  }
3024
3245
  const at = new Date(input.at);
3025
3246
  if (at.getTime() <= Date.now() + 30_000)
3026
- throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3247
+ throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
3027
3248
  const current = await schedules.forDocument(existing.id);
3028
3249
  if (input.action === 'publish')
3029
3250
  publishableData(collection(existing.collection), existing.data);
3030
3251
  else {
3031
3252
  if (!existing.publishedAt && !current.publish)
3032
- throw new ApiError(409, 'not_published', 'La pagina non è sul sito: programma prima la pubblicazione');
3253
+ throw new ApiError(409, 'not_published', t('La pagina non è sul sito: programma prima la pubblicazione'));
3033
3254
  if (current.publish && current.publish.runAt >= at)
3034
- throw new ApiError(400, 'before_publish', 'La pagina verrebbe tolta prima di essere pubblicata');
3255
+ throw new ApiError(400, 'before_publish', t('La pagina verrebbe tolta prima di essere pubblicata'));
3035
3256
  }
3036
3257
  if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
3037
- throw new ApiError(400, 'after_unpublish', 'La pubblicazione verrebbe dopo il momento in cui la pagina va tolta');
3258
+ throw new ApiError(400, 'after_unpublish', t('La pubblicazione verrebbe dopo il momento in cui la pagina va tolta'));
3038
3259
  await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
3039
3260
  await record(c, 'document.scheduled', existing.title, {
3040
3261
  collection: existing.collection,
@@ -3048,12 +3269,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3048
3269
  const publishProblem = (doc) => {
3049
3270
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
3050
3271
  if (!def)
3051
- return 'La raccolta non esiste più';
3272
+ return t.keep('La raccolta non esiste più');
3052
3273
  const check = validate(def, prepare(def, doc.data));
3053
3274
  if (check.ok)
3054
3275
  return null;
3055
3276
  const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
3056
- return `Alcuni campi vanno completati${fields.length ? `: ${fields.join(', ')}` : ''}`;
3277
+ return fields.length
3278
+ ? t.keep('Alcuni campi vanno completati: {fields}', { fields: fields.join(', ') })
3279
+ : t.keep('Alcuni campi vanno completati');
3057
3280
  };
3058
3281
  /**
3059
3282
  * Runs the actions whose time has come, as the person who set them. A server that was off at the
@@ -3080,7 +3303,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3080
3303
  .catch((error) => console.error('Materialize: activity not recorded', error));
3081
3304
  const existing = await content.get(item.documentId);
3082
3305
  if (!existing) {
3083
- await schedules.finish(item.id, 'failed', 'La pagina non esiste più');
3306
+ await schedules.finish(item.id, 'failed', t.keep('La pagina non esiste più'));
3084
3307
  continue;
3085
3308
  }
3086
3309
  try {
@@ -3100,7 +3323,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3100
3323
  ran++;
3101
3324
  }
3102
3325
  catch (error) {
3103
- const message = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3326
+ const message = error instanceof Error && error.message ? error.message : t.keep('Errore imprevisto');
3104
3327
  await schedules.finish(item.id, 'failed', message);
3105
3328
  await note('document.schedule_failed', existing.title, {
3106
3329
  collection: existing.collection,
@@ -3146,7 +3369,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3146
3369
  for (const item of release.items) {
3147
3370
  const doc = await content.get(item.documentId);
3148
3371
  if (!doc)
3149
- out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
3372
+ out.push({ documentId: item.documentId, title: '', message: t.keep('La pagina non esiste più') });
3150
3373
  else if (item.action === 'publish') {
3151
3374
  const message = publishProblem(doc);
3152
3375
  if (message)
@@ -3154,7 +3377,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3154
3377
  }
3155
3378
  }
3156
3379
  if (!release.items.length)
3157
- out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
3380
+ out.push({ documentId: '', title: '', message: t.keep('Il rilascio è vuoto') });
3158
3381
  return out;
3159
3382
  };
3160
3383
  /**
@@ -3167,7 +3390,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3167
3390
  .catch((error) => console.error('Materialize: activity not recorded', error));
3168
3391
  const problems = await releaseProblems(release);
3169
3392
  if (problems.length) {
3170
- const message = problems.map((p) => (p.title ? `${p.title}: ${p.message}` : p.message)).join('; ');
3393
+ const message = t.keep('{list}', {
3394
+ list: problems.map((p) => p.title ? t.keep('{title}: {message}', { title: p.title, message: p.message }) : p.message),
3395
+ });
3171
3396
  await releases.finish(release.id, 'failed', message);
3172
3397
  await note('release.failed', release.name, { id: release.id, message });
3173
3398
  return false;
@@ -3190,8 +3415,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3190
3415
  }
3191
3416
  }
3192
3417
  catch (error) {
3193
- const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3194
- const message = `${why}${done.length ? ` (già uscite: ${done.join(', ')})` : ''}`;
3418
+ const why = error instanceof Error && error.message ? error.message : t.keep('Errore imprevisto');
3419
+ const message = done.length ? t.keep('{why} (già uscite: {done})', { why, done: done.join(', ') }) : why;
3195
3420
  await releases.finish(release.id, 'failed', message);
3196
3421
  await note('release.failed', release.name, { id: release.id, message });
3197
3422
  return false;
@@ -3214,21 +3439,21 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3214
3439
  collection: doc.collection,
3215
3440
  locale: doc.locale,
3216
3441
  status: doc.status,
3217
- problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
3442
+ problem: t.read(problems.find((p) => p.documentId === doc.id)?.message) || null,
3218
3443
  });
3219
3444
  }
3220
- return { ...release, items, ready: problems.length === 0 };
3445
+ return { ...release, error: release.error && t.read(release.error), items, ready: problems.length === 0 };
3221
3446
  };
3222
3447
  const loadRelease = async (id) => {
3223
3448
  const release = await releases.get(id);
3224
3449
  if (!release)
3225
- throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
3450
+ throw new ApiError(404, 'not_found', t('Questo rilascio non esiste'));
3226
3451
  return release;
3227
3452
  };
3228
3453
  const openRelease = async (id) => {
3229
3454
  const release = await loadRelease(id);
3230
3455
  if (release.status === 'published' || release.status === 'cancelled')
3231
- throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
3456
+ throw new ApiError(409, 'release_closed', t('Questo rilascio è già uscito o è stato annullato'));
3232
3457
  return release;
3233
3458
  };
3234
3459
  app.get('/api/releases', requireUser, async (c) => {
@@ -3264,7 +3489,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3264
3489
  const doc = await loadDocument(c.req.param('documentId'));
3265
3490
  const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
3266
3491
  if (input.action === 'unpublish' && !doc.publishedAt)
3267
- throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
3492
+ throw new ApiError(409, 'not_published', t('La pagina non è sul sito: non c’è niente da togliere'));
3268
3493
  await releases.put(release.id, doc.id, input.action);
3269
3494
  await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
3270
3495
  return c.json(await releaseJson(await loadRelease(release.id)));
@@ -3282,15 +3507,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3282
3507
  return c.json({
3283
3508
  error: {
3284
3509
  code: 'release_not_ready',
3285
- message: 'Alcune pagine non sono pronte: non è uscito niente',
3286
- problems,
3510
+ message: t('Alcune pagine non sono pronte: non è uscito niente'),
3511
+ problems: problems.map((p) => ({ ...p, message: t.read(p.message) })),
3287
3512
  },
3288
3513
  }, 422);
3289
3514
  const user = c.get('user');
3290
3515
  const ok = await releaseOut(release, user);
3291
3516
  const after = await loadRelease(release.id);
3292
3517
  if (!ok)
3293
- throw new ApiError(422, 'release_failed', after.error ?? 'Il rilascio non è uscito');
3518
+ throw new ApiError(422, 'release_failed', after.error ? t.read(after.error) : t('Il rilascio non è uscito'));
3294
3519
  return c.json(await releaseJson(after));
3295
3520
  });
3296
3521
  /** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
@@ -3305,9 +3530,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3305
3530
  }
3306
3531
  const at = new Date(input.at);
3307
3532
  if (at.getTime() <= Date.now() + 30_000)
3308
- throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3533
+ throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
3309
3534
  if (!release.items.length)
3310
- throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
3535
+ throw new ApiError(400, 'empty_release', t('Aggiungi prima qualcosa al rilascio'));
3311
3536
  await releases.schedule(release.id, at, user);
3312
3537
  await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
3313
3538
  return c.json(await releaseJson(await loadRelease(release.id)));
@@ -3476,7 +3701,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3476
3701
  app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
3477
3702
  const found = await newsletter.subscriber(c.req.param('id'));
3478
3703
  if (!found)
3479
- throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
3704
+ throw new ApiError(404, 'not_found', t('Questo iscritto non esiste'));
3480
3705
  await newsletter.removeSubscriber(found.id);
3481
3706
  await record(c, 'newsletter.subscriber_removed', found.email);
3482
3707
  return c.body(null, 204);
@@ -3485,7 +3710,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3485
3710
  const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
3486
3711
  const rows = parseSubscribersCsv(input.csv);
3487
3712
  if (!rows.length)
3488
- throw new ApiError(422, 'no_addresses', 'Nel file non ho trovato indirizzi email');
3713
+ throw new ApiError(422, 'no_addresses', t('Nel file non ho trovato indirizzi email'));
3489
3714
  const result = await newsletter.importMany(rows);
3490
3715
  await record(c, 'newsletter.imported', '', result);
3491
3716
  return c.json({ found: rows.length, ...result });
@@ -3524,16 +3749,20 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3524
3749
  return c.json(input);
3525
3750
  });
3526
3751
  // ----- panel: newsletters -----
3527
- const newsletterJson = async (n) => ({ ...n, stats: await newsletter.stats(n.id) });
3752
+ const newsletterJson = async (n) => ({
3753
+ ...n,
3754
+ error: n.error && t.read(n.error),
3755
+ stats: await newsletter.stats(n.id),
3756
+ });
3528
3757
  const loadNewsletter = async (id) => {
3529
3758
  const found = await newsletter.get(id);
3530
3759
  if (!found)
3531
- throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
3760
+ throw new ApiError(404, 'not_found', t('Questa newsletter non esiste'));
3532
3761
  return found;
3533
3762
  };
3534
3763
  const editable = (n) => {
3535
3764
  if (n.status === 'sending' || n.status === 'sent')
3536
- throw new ApiError(409, 'newsletter_sent', 'Questa newsletter è già partita: non si può più cambiare');
3765
+ throw new ApiError(409, 'newsletter_sent', t('Questa newsletter è già partita: non si può più cambiare'));
3537
3766
  };
3538
3767
  const newsletterBody = z.object({
3539
3768
  subject: z.string().trim().min(1).max(200),
@@ -3558,7 +3787,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3558
3787
  app.delete('/api/newsletters/:id', requireUser, async (c) => {
3559
3788
  const found = await loadNewsletter(c.req.param('id'));
3560
3789
  if (found.status === 'sending')
3561
- throw new ApiError(409, 'newsletter_sending', 'La newsletter sta partendo: aspetta che finisca');
3790
+ throw new ApiError(409, 'newsletter_sending', t('La newsletter sta partendo: aspetta che finisca'));
3562
3791
  await newsletter.remove(found.id);
3563
3792
  await record(c, 'newsletter.deleted', found.subject);
3564
3793
  return c.body(null, 204);
@@ -3601,17 +3830,25 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3601
3830
  const readyMail = async () => {
3602
3831
  const mail = await settings.get('mail');
3603
3832
  if (!isConfigured(mail))
3604
- throw new ApiError(400, 'mail_not_configured', 'Per inviare la newsletter serve l’email: Impostazioni → Email');
3833
+ throw new ApiError(400, 'mail_not_configured', t('Per inviare la newsletter serve l’email: Impostazioni → Email'));
3605
3834
  return mail;
3606
3835
  };
3607
3836
  /** A try, to the person writing it (or another address): nothing is counted. */
3837
+ // Each try is an email to any address: a few an hour are plenty to check one.
3838
+ const allowNewsletterTest = createLimiter(10, 60 * 60_000);
3608
3839
  app.post('/api/newsletters/:id/test', requireUser, async (c) => {
3840
+ if (!allowNewsletterTest(c.get('user').id))
3841
+ throw new ApiError(429, 'too_many_attempts', t('Troppe prove: riprova tra un’ora'));
3609
3842
  const found = await loadNewsletter(c.req.param('id'));
3610
3843
  const input = await body(c, z.object({ to: z.email().optional() }));
3611
3844
  const mail = await readyMail();
3612
3845
  await newsletterOrigin(c);
3613
3846
  const message = await composeNewsletter(found, null);
3614
- await sendMail(mail, { to: input.to ?? c.get('user').email, ...message, subject: `[Prova] ${message.subject}` });
3847
+ await sendMail(mail, {
3848
+ to: input.to ?? c.get('user').email,
3849
+ ...message,
3850
+ subject: t('[Prova] {subject}', { subject: message.subject }),
3851
+ });
3615
3852
  return c.json({ ok: true });
3616
3853
  });
3617
3854
  app.post('/api/newsletters/:id/send', requireUser, async (c) => {
@@ -3621,7 +3858,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3621
3858
  await newsletterOrigin(c);
3622
3859
  const { counts } = await newsletter.subscribers({ limit: 1 });
3623
3860
  if (!counts.active)
3624
- throw new ApiError(400, 'no_subscribers', 'Non ci sono ancora iscritti attivi');
3861
+ throw new ApiError(400, 'no_subscribers', t('Non ci sono ancora iscritti attivi'));
3625
3862
  await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
3626
3863
  await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
3627
3864
  void runNewsletters();
@@ -3637,7 +3874,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3637
3874
  }
3638
3875
  const at = new Date(input.at);
3639
3876
  if (at.getTime() <= Date.now() + 30_000)
3640
- throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3877
+ throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
3641
3878
  await readyMail();
3642
3879
  await newsletterOrigin(c);
3643
3880
  await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
@@ -3687,7 +3924,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3687
3924
  const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
3688
3925
  const user = c.get('user');
3689
3926
  if (!allowAi(user.id))
3690
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
3927
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
3691
3928
  const ai = await readyAi();
3692
3929
  const config = ctx.config();
3693
3930
  const lang = config.locales.default;
@@ -3717,7 +3954,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3717
3954
  items.sort((a, b) => b.date - a.date);
3718
3955
  const latest = items.slice(0, input.count);
3719
3956
  if (!latest.length)
3720
- throw new ApiError(400, 'no_articles', 'Non ci sono ancora articoli pubblicati da raccontare');
3957
+ throw new ApiError(400, 'no_articles', t('Non ci sono ancora articoli pubblicati da raccontare'));
3721
3958
  const voice = await voiceText();
3722
3959
  const system = `${voice ? `${voice}\n\n` : ''}You write the email newsletter of the website "${config.site.name}", in ${languageName(lang)}. You get its latest articles; write a short newsletter that makes people want to read them: a subject (at most 70 characters), a preheader (one sentence, at most 120 characters), an opening of one or two sentences, then for each article a heading with its title and two or three sentences on why it is worth reading, ending with a link to it. Use only facts from the articles. Reply with JSON only: {"subject": "...", "preheader": "...", "blocks": [{"type": "paragraph"|"heading", "text": "...", "link": {"text": "...", "href": "..."}?}]}.`;
3723
3960
  const prompt = `${input.note ? `<note>${input.note}</note>\n` : ''}${latest.map((a) => `<article title="${a.title.replace(/"/g, "'")}" url="${a.url}">\n${a.text}\n</article>`).join('\n')}`;
@@ -3727,7 +3964,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3727
3964
  parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
3728
3965
  }
3729
3966
  catch {
3730
- throw new ApiError(422, 'ai_failed', 'La risposta dell’AI non era leggibile: riprova');
3967
+ throw new ApiError(422, 'ai_failed', t('La risposta dell’AI non era leggibile: riprova'));
3731
3968
  }
3732
3969
  const known = new Set(latest.map((a) => a.url));
3733
3970
  const blocks = [];
@@ -3763,7 +4000,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3763
4000
  for (const n of await newsletter.due(now)) {
3764
4001
  const mail = await settings.get('mail');
3765
4002
  if (!isConfigured(mail)) {
3766
- await newsletter.setStatus(n.id, 'failed', { error: 'L’email non è configurata' });
4003
+ await newsletter.setStatus(n.id, 'failed', { error: t.keep('L’email non è configurata') });
3767
4004
  continue;
3768
4005
  }
3769
4006
  if (n.status === 'scheduled')
@@ -3776,7 +4013,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3776
4013
  for (const item of batch) {
3777
4014
  const who = await newsletter.subscriber(item.subscriberId);
3778
4015
  if (!who || who.status !== 'active') {
3779
- await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
4016
+ await newsletter.markSend(item.id, 'failed', t.keep('Non più iscritto'));
3780
4017
  continue;
3781
4018
  }
3782
4019
  try {
@@ -3786,7 +4023,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3786
4023
  sent++;
3787
4024
  }
3788
4025
  catch (error) {
3789
- await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : 'Errore');
4026
+ await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : t.keep('Errore'));
3790
4027
  }
3791
4028
  if (pause)
3792
4029
  await new Promise((r) => setTimeout(r, pause));
@@ -3796,7 +4033,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3796
4033
  const allFailed = stats.total > 0 && stats.sent === 0;
3797
4034
  await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
3798
4035
  sentAt: new Date(),
3799
- error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
4036
+ error: allFailed ? t.keep('Nessun invio riuscito: controlla Impostazioni → Email') : null,
3800
4037
  });
3801
4038
  }
3802
4039
  // Signups never confirmed are dropped after a week.
@@ -3829,9 +4066,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3829
4066
  const existing = await loadDocument(c.req.param('id'));
3830
4067
  const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
3831
4068
  if (!ctx.preview)
3832
- throw new ApiError(409, 'no_site', 'Questo sito non ha pagine da mostrare in anteprima');
4069
+ throw new ApiError(409, 'no_site', t('Questo sito non ha pagine da mostrare in anteprima'));
3833
4070
  if (!collection(existing.collection).url)
3834
- throw new ApiError(400, 'no_page', 'Questo contenuto non ha una pagina sul sito');
4071
+ throw new ApiError(400, 'no_page', t('Questo contenuto non ha una pagina sul sito'));
3835
4072
  const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
3836
4073
  const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
3837
4074
  await record(c, 'document.preview_shared', existing.title, {
@@ -3844,7 +4081,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3844
4081
  app.delete('/api/previews/:id', requireUser, async (c) => {
3845
4082
  const link = await previewLinks.get(c.req.param('id'));
3846
4083
  if (!link)
3847
- throw new ApiError(404, 'not_found', 'Link non trovato');
4084
+ throw new ApiError(404, 'not_found', t('Link non trovato'));
3848
4085
  const doc = await content.get(link.documentId);
3849
4086
  await previewLinks.revoke(link.id);
3850
4087
  await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
@@ -3875,6 +4112,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3875
4112
  app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
3876
4113
  const existing = await loadDocument(c.req.param('id'));
3877
4114
  guardTurn(c, existing.id);
4115
+ // An old version is a save like any other: it may hold code or a form's address an editor
4116
+ // could not write today (an admin's version, or one from before the rules changed).
4117
+ const revision = await content.revision(existing.id, c.req.param('revision'));
4118
+ if (!revision)
4119
+ throw new ApiError(404, 'not_found', 'This version does not exist');
4120
+ guardEmbeds(c.get('user'), existing.data, revision.data);
4121
+ for (const [name, f] of Object.entries(collection(existing.collection).fields))
4122
+ if (f.kind === 'page')
4123
+ await guardDesign(c.get('user'), existing.data[name], revision.data[name]);
3878
4124
  const doc = await content.restore(existing.id, c.req.param('revision'), c.get('user').id);
3879
4125
  if (!doc)
3880
4126
  throw new ApiError(404, 'not_found', 'This version does not exist');
@@ -3896,11 +4142,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3896
4142
  });
3897
4143
  app.put('/api/locales', requireUser, requireSession, requireAdmin, async (c) => {
3898
4144
  const input = await body(c, z.object({
3899
- default: z.string().refine(isLocaleCode, 'Codice lingua non valido'),
3900
- available: z.array(z.string().refine(isLocaleCode, 'Codice lingua non valido')).min(1).max(30),
4145
+ default: z.string().refine(isLocaleCode, t('Codice lingua non valido')),
4146
+ available: z
4147
+ .array(z.string().refine(isLocaleCode, t('Codice lingua non valido')))
4148
+ .min(1)
4149
+ .max(30),
3901
4150
  }));
3902
4151
  if (!input.available.includes(input.default))
3903
- throw new ApiError(400, 'invalid_locales', 'La lingua principale deve essere tra quelle del sito');
4152
+ throw new ApiError(400, 'invalid_locales', t('La lingua principale deve essere tra quelle del sito'));
3904
4153
  const value = { default: input.default, available: [...new Set(input.available)] };
3905
4154
  await settings.set('locales', value, c.get('user').id);
3906
4155
  languages = value;
@@ -3938,11 +4187,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3938
4187
  const lang = locale(input.locale);
3939
4188
  const group = await content.translations(doc.id);
3940
4189
  if (group.some((t) => t.locale === lang))
3941
- throw new ApiError(409, 'translation_exists', 'Questa lingua esiste già: aprila dall’elenco delle lingue');
4190
+ throw new ApiError(409, 'translation_exists', t('Questa lingua esiste già: aprila dall’elenco delle lingue'));
3942
4191
  if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0)
3943
- throw new ApiError(409, 'singleton_exists', `"${def.label}" esiste già in questa lingua`);
4192
+ throw new ApiError(409, 'singleton_exists', t('"{label}" esiste già in questa lingua', { label: def.label }));
3944
4193
  if (input.translate && !allowAi(c.get('user').id))
3945
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
4194
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
3946
4195
  const data = structuredClone(doc.data);
3947
4196
  if (input.translate) {
3948
4197
  // The address is translated too ("chi-siamo" → "about-us"), the home page keeps its own.
@@ -3977,7 +4226,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3977
4226
  const input = await body(c, z.object({ translate: z.boolean().default(false) }));
3978
4227
  const doc = await loadDocument(c.req.param('id'));
3979
4228
  if (!doc.sourceId)
3980
- throw new ApiError(400, 'not_a_translation', 'Questa è la versione originale');
4229
+ throw new ApiError(400, 'not_a_translation', t('Questa è la versione originale'));
3981
4230
  const original = await loadDocument(doc.sourceId);
3982
4231
  const def = collection(doc.collection);
3983
4232
  const data = structuredClone(doc.data);
@@ -3993,7 +4242,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3993
4242
  }
3994
4243
  if (input.translate && fresh.size) {
3995
4244
  if (!allowAi(c.get('user').id))
3996
- throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
4245
+ throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
3997
4246
  const ai = await readyAi();
3998
4247
  for (const [name, f] of Object.entries(def.fields)) {
3999
4248
  if (f.kind !== 'page' || !Array.isArray(data[name]))
@@ -4031,7 +4280,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
4031
4280
  wpJobs.delete(k);
4032
4281
  };
4033
4282
  /** A WordPress site is read from its address (REST API) or from its export file. */
4034
- app.post('/api/import/wordpress/analyze', requireUser, requireSession, requireAdmin, async (c) => {
4283
+ app.post('/api/import/wordpress/analyze', requireUser, requireSession, requireAdmin, bodyLimit({
4284
+ maxSize: WXR_MAX,
4285
+ onError: () => {
4286
+ throw new ApiError(413, 'file_too_large', t('Il file è troppo grande'));
4287
+ },
4288
+ }), async (c) => {
4035
4289
  forget();
4036
4290
  const isForm = (c.req.header('content-type') ?? '').includes('multipart/form-data');
4037
4291
  let site;
@@ -4065,7 +4319,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
4065
4319
  if (error instanceof WordPressError)
4066
4320
  throw new ApiError(422, 'wordpress_unreadable', error.message);
4067
4321
  if (error instanceof RemoteError)
4068
- throw new ApiError(422, 'wordpress_unreadable', `Il sito non risponde: ${error.message}`);
4322
+ throw new ApiError(422, 'wordpress_unreadable', t('Il sito non risponde: {message}', { message: error.message }));
4069
4323
  throw error;
4070
4324
  }
4071
4325
  const token = randomBytes(16).toString('hex');
@@ -4122,12 +4376,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
4122
4376
  const relayoutPage = async (ai, id, user, credentials) => {
4123
4377
  const doc = await content.get(id);
4124
4378
  if (!doc)
4125
- return 'la pagina non c’è più';
4379
+ return t('la pagina non c’è più');
4126
4380
  const def = collection(doc.collection);
4127
4381
  const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
4128
4382
  const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
4129
4383
  if (!field || !before?.length)
4130
- return 'la pagina è vuota';
4384
+ return t('la pagina è vuota');
4131
4385
  const page = { nodes: before };
4132
4386
  const call = apiCaller(app.fetch, credentials);
4133
4387
  const getSchema = () => call('GET', '/api/schema');
@@ -4219,15 +4473,15 @@ When done, reply with one short sentence.`;
4219
4473
  }
4220
4474
  catch (error) {
4221
4475
  return {
4222
- error: error instanceof RemoteError && /too large/.test(error.message)
4223
- ? 'troppo grandi (oltre 25 MB)'
4224
- : 'non raggiungibili',
4476
+ error: error instanceof RemoteError && error.reason === 'too_large'
4477
+ ? t('troppo grandi (oltre 25 MB)')
4478
+ : t('non raggiungibili'),
4225
4479
  };
4226
4480
  }
4227
4481
  if (res.status !== 200)
4228
- return { error: `non trovate sul sito (${res.status})` };
4482
+ return { error: t('non trovate sul sito ({status})', { status: res.status }) };
4229
4483
  if (/svg/.test(res.type) || /\.svg$/i.test(res.name))
4230
- return { error: 'SVG: la libreria non li accetta, perché possono contenere codice' };
4484
+ return { error: t('SVG: la libreria non li accetta, perché possono contenere codice') };
4231
4485
  try {
4232
4486
  const saved = await files.save(res.name, res.bytes);
4233
4487
  const asset = await mediaStore.create({ ...saved, filename: res.name.slice(0, 200), alt, createdBy: user.id });
@@ -4241,7 +4495,7 @@ When done, reply with one short sentence.`;
4241
4495
  return media;
4242
4496
  }
4243
4497
  catch {
4244
- return { error: 'in un formato che la libreria non legge' };
4498
+ return { error: t('in un formato che la libreria non legge') };
4245
4499
  }
4246
4500
  },
4247
4501
  fetchPage: async (url) => {
@@ -4458,7 +4712,7 @@ When done, reply with one short sentence.`;
4458
4712
  // ---------- public delivery API: published content only ----------
4459
4713
  const publicDocument = (doc) => {
4460
4714
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
4461
- const data = doc.publishedData ?? {};
4715
+ const data = withoutFormSettings(doc.publishedData ?? {});
4462
4716
  return {
4463
4717
  id: doc.id,
4464
4718
  slug: doc.slug,