@materializecms/server 0.7.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' }));
@@ -1676,6 +1870,69 @@ export function createApp(base) {
1676
1870
  await comments.remove(comment.id);
1677
1871
  return c.body(null, 204);
1678
1872
  });
1873
+ const turns = new Map();
1874
+ const TURN_TTL = 45_000;
1875
+ const turnOf = (docId) => {
1876
+ const turn = turns.get(docId);
1877
+ if (turn && Date.now() - turn.at > TURN_TTL) {
1878
+ turns.delete(docId);
1879
+ return null;
1880
+ }
1881
+ return turn ?? null;
1882
+ };
1883
+ const turnJson = (turn, user, tab) => ({
1884
+ mine: !!turn && turn.userId === user.id && turn.tab === tab,
1885
+ holder: turn ? { name: turn.name, since: new Date(turn.since).toISOString(), self: turn.userId === user.id } : null,
1886
+ });
1887
+ /** Holds the turn (or asks for it); take: true takes it from whoever has it. */
1888
+ app.post('/api/documents/:id/turn', requireUser, async (c) => {
1889
+ const input = await body(c, z.object({ tab: z.string().min(8).max(64), take: z.boolean().optional() }));
1890
+ const user = c.get('user');
1891
+ const docId = c.req.param('id');
1892
+ const now = Date.now();
1893
+ for (const [k, t] of turns)
1894
+ if (now - t.at > TURN_TTL * 4)
1895
+ turns.delete(k);
1896
+ const turn = turnOf(docId);
1897
+ if (!turn || (turn.userId === user.id && turn.tab === input.tab) || input.take) {
1898
+ if (input.take && turn && turn.tab !== input.tab) {
1899
+ // The one who had it learns who took it, at their next beat.
1900
+ turns.set(`lost:${docId}:${turn.tab}`, { ...turn, takenBy: user.name, at: now });
1901
+ const doc = await content.get(docId);
1902
+ await record(c, 'document.turn_taken', doc?.title ?? '', { id: docId, from: turn.name });
1903
+ }
1904
+ const keep = turn && turn.userId === user.id && turn.tab === input.tab;
1905
+ turns.set(docId, { userId: user.id, name: user.name, tab: input.tab, since: keep ? turn.since : now, at: now });
1906
+ return c.json(turnJson(turnOf(docId), user, input.tab));
1907
+ }
1908
+ const taken = turns.get(`lost:${docId}:${input.tab}`);
1909
+ if (taken)
1910
+ turns.delete(`lost:${docId}:${input.tab}`);
1911
+ return c.json({ ...turnJson(turn, user, input.tab), takenBy: taken?.takenBy ?? null });
1912
+ });
1913
+ /** Leaves the turn: the document closed. */
1914
+ app.delete('/api/documents/:id/turn', requireUser, async (c) => {
1915
+ const docId = c.req.param('id');
1916
+ const turn = turnOf(docId);
1917
+ if (turn && turn.userId === c.get('user').id && turn.tab === c.req.query('tab'))
1918
+ turns.delete(docId);
1919
+ return c.body(null, 204);
1920
+ });
1921
+ /**
1922
+ * Changes to a document from someone who is not on turn are refused: from another person, or
1923
+ * from another tab of the same person. The API and AI tools of the person on turn pass.
1924
+ */
1925
+ const guardTurn = (c, docId) => {
1926
+ const turn = turnOf(docId);
1927
+ if (!turn)
1928
+ return;
1929
+ const user = c.get('user');
1930
+ const tab = c.req.header('x-mz-tab');
1931
+ if (turn.userId !== user.id)
1932
+ throw new ApiError(409, 'not_your_turn', t('{name} sta modificando questo documento: le tue modifiche non sono state salvate', { name: turn.name }));
1933
+ if (tab && tab !== turn.tab)
1934
+ throw new ApiError(409, 'not_your_turn', t('Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate'));
1935
+ };
1679
1936
  app.post('/api/documents/:id/presence', requireUser, async (c) => {
1680
1937
  const input = await body(c, z.object({ selected: z.string().max(200).nullable() }));
1681
1938
  const user = c.get('user');
@@ -1705,26 +1962,37 @@ export function createApp(base) {
1705
1962
  return false;
1706
1963
  }
1707
1964
  };
1708
- /**
1709
- * The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
1710
- * is known. The request's Host header is used only without it, as in local development: anyone
1711
- * can send a request with a false Host, and a link to their server would carry the token there.
1712
- */
1713
- const publicOrigin = (c) => {
1965
+ /** The site's own address (MATERIALIZE_SITE_URL, site.url), when it is set. */
1966
+ const siteOrigin = () => {
1714
1967
  const site = ctx.config().site.url;
1715
1968
  try {
1716
1969
  if (site)
1717
1970
  return new URL(site).origin;
1718
1971
  }
1719
1972
  catch { }
1720
- return new URL(c.req.url).origin;
1973
+ return null;
1721
1974
  };
1722
- 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) => {
1723
1991
  const mail = await settings.get('mail');
1724
1992
  if (!isConfigured(mail))
1725
1993
  return false;
1726
1994
  try {
1727
- const url = new URL(path, publicOrigin(c)).toString();
1995
+ const url = new URL(path, origin).toString();
1728
1996
  await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
1729
1997
  return true;
1730
1998
  }
@@ -1749,7 +2017,9 @@ export function createApp(base) {
1749
2017
  }),
1750
2018
  }));
1751
2019
  const before = await settings.get('mail');
1752
- 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 : '') || '';
1753
2023
  const mail = { ...input.mail, pass };
1754
2024
  await settings.set('mail', mail, c.get('user').id);
1755
2025
  await record(c, 'settings.mail');
@@ -1763,8 +2033,8 @@ export function createApp(base) {
1763
2033
  try {
1764
2034
  await sendMail(mail, {
1765
2035
  to,
1766
- subject: 'Prova di invio da Materialize',
1767
- 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.'),
1768
2038
  });
1769
2039
  }
1770
2040
  catch (error) {
@@ -1805,7 +2075,7 @@ export function createApp(base) {
1805
2075
  const readyAi = async () => {
1806
2076
  const ai = await aiSettings();
1807
2077
  if (!isReady(ai))
1808
- 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'));
1809
2079
  return { ...ai, onUsage: countUsage };
1810
2080
  };
1811
2081
  const aiFailed = (error) => {
@@ -1830,7 +2100,9 @@ export function createApp(base) {
1830
2100
  }),
1831
2101
  }));
1832
2102
  const before = await aiSettings();
1833
- 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 : '');
1834
2106
  const ai = { ...input.ai, apiKey };
1835
2107
  await settings.set('ai', ai, c.get('user').id);
1836
2108
  await record(c, 'settings.ai', ai.provider);
@@ -1869,7 +2141,7 @@ export function createApp(base) {
1869
2141
  */
1870
2142
  app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
1871
2143
  if (!allowAi(c.get('user').id))
1872
- 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'));
1873
2145
  const ai = await readyAi();
1874
2146
  const config = ctx.config();
1875
2147
  const texts = [];
@@ -1886,7 +2158,7 @@ export function createApp(base) {
1886
2158
  }
1887
2159
  }
1888
2160
  if (size < 300)
1889
- 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'));
1890
2162
  const reply = await complete(ai, {
1891
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.',
1892
2164
  prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
@@ -1916,7 +2188,7 @@ ${texts.join('\n---\n')}
1916
2188
  address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
1917
2189
  });
1918
2190
  if (!parsed.success)
1919
- 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'));
1920
2192
  return c.json({ voice: parsed.data, texts: texts.length });
1921
2193
  });
1922
2194
  // ---------- AI review before publishing ----------
@@ -1946,12 +2218,12 @@ ${texts.join('\n---\n')}
1946
2218
  const existing = await loadDocument(c.req.param('id'));
1947
2219
  const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
1948
2220
  if (!allowAi(c.get('user').id))
1949
- 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'));
1950
2222
  const ai = await readyAi();
1951
2223
  const def = collection(existing.collection);
1952
2224
  const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
1953
2225
  if (!slots.length)
1954
- throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
2226
+ throw new ApiError(400, 'empty_text', t('Non c’è testo da rivedere'));
1955
2227
  const titles = await pageTitles();
1956
2228
  const linkTo = (href) => {
1957
2229
  const path = href.split('#')[0] || href;
@@ -1991,7 +2263,7 @@ ${listed.join('\n')}
1991
2263
  raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
1992
2264
  }
1993
2265
  catch {
1994
- 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'));
1995
2267
  }
1996
2268
  const issue = z.object({
1997
2269
  slot: z.number().int().min(0),
@@ -2037,7 +2309,7 @@ ${listed.join('\n')}
2037
2309
  ? slots[input.slot]
2038
2310
  : slots.find((s) => s.text.includes(input.find));
2039
2311
  if (!slot)
2040
- throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
2312
+ throw new ApiError(409, 'changed', t('Quel testo è cambiato: rifai la revisione'));
2041
2313
  slot.set(slot.text.replace(input.find, input.replace));
2042
2314
  return c.json({ data });
2043
2315
  });
@@ -2067,7 +2339,7 @@ ${listed.join('\n')}
2067
2339
  max: z.number().int().positive().max(100_000).optional(),
2068
2340
  }));
2069
2341
  if (!allowAi(c.get('user').id))
2070
- 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'));
2071
2343
  const ai = await readyAi();
2072
2344
  // Writing from scratch takes the whole voice; changing a text, only its style.
2073
2345
  const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
@@ -2079,7 +2351,7 @@ ${listed.join('\n')}
2079
2351
  const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
2080
2352
  const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
2081
2353
  if (!file)
2082
- 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'));
2083
2355
  const data = (await readFile(file.path)).toString('base64');
2084
2356
  // The text already there may credit the photographer or the source: that part stays.
2085
2357
  const current = input.text.trim()
@@ -2094,7 +2366,7 @@ ${listed.join('\n')}
2094
2366
  return c.json({ text });
2095
2367
  }
2096
2368
  if (!input.text.trim())
2097
- 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'));
2098
2370
  const task = input.action === 'translate'
2099
2371
  ? `Translate it into ${input.instruction || 'English'}, naturally, as a native writer would.`
2100
2372
  : input.action === 'custom'
@@ -2158,7 +2430,7 @@ ${listed.join('\n')}
2158
2430
  }));
2159
2431
  const user = c.get('user');
2160
2432
  if (!allowAi(user.id))
2161
- 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'));
2162
2434
  const ai = await readyAi();
2163
2435
  const now = Date.now();
2164
2436
  for (const [k, v] of chats)
@@ -2210,7 +2482,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2210
2482
  if (limited && !sameDesign(before, page.nodes))
2211
2483
  await send({
2212
2484
  type: 'error',
2213
- 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.'),
2214
2486
  });
2215
2487
  else
2216
2488
  await send({ type: 'page', nodes: page.nodes });
@@ -2223,7 +2495,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2223
2495
  console.error(error);
2224
2496
  // A failed turn leaves the conversation as it was before it.
2225
2497
  chats.delete(key);
2226
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2498
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2227
2499
  await send({ type: 'done', chatId });
2228
2500
  }
2229
2501
  });
@@ -2235,7 +2507,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2235
2507
  app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
2236
2508
  const input = await body(c, z
2237
2509
  .object({
2238
- 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(),
2239
2511
  /** A reply in a run already started, with its id. */
2240
2512
  runId: z.string().max(64).optional(),
2241
2513
  message: z.string().trim().min(1).max(4000).optional(),
@@ -2248,10 +2520,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2248
2520
  .max(2)
2249
2521
  .default([]),
2250
2522
  })
2251
- .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') }));
2252
2524
  const user = c.get('user');
2253
2525
  if (!allowAi(user.id))
2254
- 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'));
2255
2527
  const ai = await readyAi();
2256
2528
  const now = Date.now();
2257
2529
  for (const [k, v] of briefRuns)
@@ -2261,12 +2533,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2261
2533
  const key = `${user.id}:${runId}`;
2262
2534
  let run = input.runId ? briefRuns.get(key) : undefined;
2263
2535
  if (input.runId && (!run || run.conv.provider !== ai.provider))
2264
- 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'));
2265
2537
  if (!run) {
2266
2538
  const config = ctx.config();
2267
2539
  const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
2268
2540
  if (!pageCollections.length)
2269
- 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'));
2270
2542
  const locale = config.locales.default;
2271
2543
  const existing = [];
2272
2544
  for (const d of config.collections.filter((x) => !x.singleton)) {
@@ -2336,7 +2608,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2336
2608
  // What was made before the failure stays, as drafts: the panel lists it. The run ends.
2337
2609
  briefRuns.delete(key);
2338
2610
  await send({ type: 'result', ...result });
2339
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2611
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2340
2612
  await send({ type: 'done' });
2341
2613
  }
2342
2614
  });
@@ -2357,7 +2629,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2357
2629
  }));
2358
2630
  const user = c.get('user');
2359
2631
  if (!allowAi(user.id))
2360
- 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'));
2361
2633
  const ai = await readyAi();
2362
2634
  const now = Date.now();
2363
2635
  for (const [k, v] of studioRuns)
@@ -2412,7 +2684,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2412
2684
  if (!(error instanceof AiError))
2413
2685
  console.error(error);
2414
2686
  studioRuns.delete(key);
2415
- await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2687
+ await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
2416
2688
  await send({ type: 'done' });
2417
2689
  }
2418
2690
  });
@@ -2469,8 +2741,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2469
2741
  }), async (c) => {
2470
2742
  const form = c.req.param('form').slice(0, 80);
2471
2743
  const ip = clientIp(c);
2472
- if (!allowForm(`${ip}:${form}`))
2473
- 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();
2474
2747
  const type = c.req.header('content-type') ?? '';
2475
2748
  const isJson = type.includes('application/json');
2476
2749
  const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
@@ -2481,6 +2754,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2481
2754
  if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
2482
2755
  return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2483
2756
  const def = (await publishedForms()).get(form) ?? null;
2757
+ if (!def && !allowHandForm(ip))
2758
+ throw tooMany();
2484
2759
  const { data, files, issues } = await readSubmission(raw, def);
2485
2760
  if (issues.length) {
2486
2761
  if (!wantsJson)
@@ -2492,7 +2767,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2492
2767
  const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
2493
2768
  const fingerprint = fingerprintOf(form, data);
2494
2769
  const spam = spamReason(data, elapsed) ??
2495
- ((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);
2496
2771
  // A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
2497
2772
  if (def?.newsletter) {
2498
2773
  if (!spam)
@@ -2501,7 +2776,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2501
2776
  }
2502
2777
  const formName = def?.name?.trim() || def?.subject?.trim() || '';
2503
2778
  const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2504
- 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)
2505
2781
  await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
2506
2782
  // Spam stays in the panel, recoverable, but nobody is notified.
2507
2783
  if (!spam) {
@@ -2510,13 +2786,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2510
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];
2511
2787
  if (isConfigured(mail) && to) {
2512
2788
  const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
2513
- const attached = files.length
2514
- ? `\n\nAllegati (${files.map((f) => f.name).join(', ')}): li trovi in Impostazioni → Messaggi.`
2515
- : '';
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
+ }));
2516
2797
  sendMail(mail, {
2517
2798
  to,
2518
- subject: `Nuovo messaggio dal sito${subject ? `: ${subject}` : ''}`,
2519
- text: submissionText(page, data) + attached,
2799
+ ...notice,
2520
2800
  replyTo: sender,
2521
2801
  })
2522
2802
  .then(() => submissions.markMailed(saved.id))
@@ -2540,7 +2820,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2540
2820
  createdAt: saved.createdAt.toISOString(),
2541
2821
  };
2542
2822
  void webhookSecret()
2543
- .then((secret) => sendWebhook(def.webhook, payload, secret))
2823
+ .then((secret) => sendWebhook(def.webhook, payload, secret, undefined, privateWebhooks))
2544
2824
  .then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
2545
2825
  .catch((error) => console.error('Materialize: webhook not sent', error));
2546
2826
  }
@@ -2556,8 +2836,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2556
2836
  app.get('/api/submissions', requireUser, async (c) => {
2557
2837
  const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
2558
2838
  const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
2839
+ const list = await submissions.list(limit, offset, filterOf(c));
2559
2840
  return c.json({
2560
- ...(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) })),
2561
2844
  forms: await submissions.forms(),
2562
2845
  });
2563
2846
  });
@@ -2571,7 +2854,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2571
2854
  return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
2572
2855
  };
2573
2856
  const rows = [
2574
- ['Data', 'Modulo', 'Pagina', ...fields],
2857
+ [t('Data'), t('Modulo'), t('Pagina'), ...fields],
2575
2858
  ...items.map((m) => [
2576
2859
  m.createdAt.toISOString(),
2577
2860
  m.formName || m.form,
@@ -2595,7 +2878,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2595
2878
  if (input.read !== undefined)
2596
2879
  await submissions.markRead(id, input.read);
2597
2880
  if (input.spam !== undefined)
2598
- await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
2881
+ await submissions.markSpam(id, input.spam ? t.keep('Segnato a mano') : null);
2599
2882
  return c.json({ ok: true });
2600
2883
  });
2601
2884
  /** A file sent with a message: always downloaded, never opened in the panel's own page. */
@@ -2603,10 +2886,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2603
2886
  const message = await submissions.get(c.req.param('id'));
2604
2887
  const file = message?.files[Number(c.req.param('n'))];
2605
2888
  if (!message || !file)
2606
- throw new ApiError(404, 'not_found', 'File non trovato');
2889
+ throw new ApiError(404, 'not_found', t('File non trovato'));
2607
2890
  const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
2608
2891
  if (!bytes)
2609
- throw new ApiError(404, 'not_found', 'Il file non c’è più');
2892
+ throw new ApiError(404, 'not_found', t('Il file non c’è più'));
2610
2893
  return new Response(bytes, {
2611
2894
  headers: {
2612
2895
  'content-type': 'application/octet-stream',
@@ -2674,14 +2957,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2674
2957
  const category = z.enum(['necessary', 'analytics', 'marketing']);
2675
2958
  const input = await body(c, z.object({
2676
2959
  tracking: z.object({
2677
- ga4: id(TRACKING_PATTERNS.ga4, 'Un ID di Google Analytics 4 inizia con G-'),
2678
- 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-')),
2679
2962
  gtmCategory: category.optional(),
2680
- metaPixel: id(TRACKING_PATTERNS.metaPixel, 'L’ID del Pixel è fatto di sole cifre'),
2681
- plausible: id(TRACKING_PATTERNS.plausible, 'Scrivi il dominio come su Plausible, per esempio example.com'),
2682
- plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, 'Un indirizzo https://'),
2683
- matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, 'L’indirizzo di Matomo, con https://'),
2684
- 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')),
2685
2968
  custom: z
2686
2969
  .array(z.object({
2687
2970
  id: z.string().min(1).max(40),
@@ -2691,7 +2974,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2691
2974
  code: z
2692
2975
  .string()
2693
2976
  .max(20_000)
2694
- .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>')),
2695
2978
  }))
2696
2979
  .max(30)
2697
2980
  .optional(),
@@ -2699,7 +2982,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2699
2982
  .object({
2700
2983
  title: z.string().trim().max(120).optional(),
2701
2984
  text: z.string().trim().max(600).optional(),
2702
- policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, 'Un indirizzo che inizia con / o https://'),
2985
+ policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, t('Un indirizzo che inizia con / o https://')),
2703
2986
  position: z.enum(['bottom', 'corner']).optional(),
2704
2987
  reopenButton: z.boolean().optional(),
2705
2988
  })
@@ -2888,6 +3171,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2888
3171
  app.get('/api/documents/:id', requireUser, async (c) => c.json(await toJson(await loadDocument(c.req.param('id')))));
2889
3172
  app.put('/api/documents/:id', requireUser, async (c) => {
2890
3173
  const existing = await loadDocument(c.req.param('id'));
3174
+ guardTurn(c, existing.id);
2891
3175
  const input = await body(c, documentBody);
2892
3176
  const def = collection(existing.collection);
2893
3177
  const draft = draftData(def, input.data);
@@ -2960,18 +3244,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2960
3244
  }
2961
3245
  const at = new Date(input.at);
2962
3246
  if (at.getTime() <= Date.now() + 30_000)
2963
- 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'));
2964
3248
  const current = await schedules.forDocument(existing.id);
2965
3249
  if (input.action === 'publish')
2966
3250
  publishableData(collection(existing.collection), existing.data);
2967
3251
  else {
2968
3252
  if (!existing.publishedAt && !current.publish)
2969
- 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'));
2970
3254
  if (current.publish && current.publish.runAt >= at)
2971
- 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'));
2972
3256
  }
2973
3257
  if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
2974
- 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'));
2975
3259
  await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
2976
3260
  await record(c, 'document.scheduled', existing.title, {
2977
3261
  collection: existing.collection,
@@ -2985,12 +3269,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2985
3269
  const publishProblem = (doc) => {
2986
3270
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
2987
3271
  if (!def)
2988
- return 'La raccolta non esiste più';
3272
+ return t.keep('La raccolta non esiste più');
2989
3273
  const check = validate(def, prepare(def, doc.data));
2990
3274
  if (check.ok)
2991
3275
  return null;
2992
3276
  const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
2993
- 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');
2994
3280
  };
2995
3281
  /**
2996
3282
  * Runs the actions whose time has come, as the person who set them. A server that was off at the
@@ -3017,7 +3303,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3017
3303
  .catch((error) => console.error('Materialize: activity not recorded', error));
3018
3304
  const existing = await content.get(item.documentId);
3019
3305
  if (!existing) {
3020
- 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ù'));
3021
3307
  continue;
3022
3308
  }
3023
3309
  try {
@@ -3037,7 +3323,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3037
3323
  ran++;
3038
3324
  }
3039
3325
  catch (error) {
3040
- 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');
3041
3327
  await schedules.finish(item.id, 'failed', message);
3042
3328
  await note('document.schedule_failed', existing.title, {
3043
3329
  collection: existing.collection,
@@ -3083,7 +3369,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3083
3369
  for (const item of release.items) {
3084
3370
  const doc = await content.get(item.documentId);
3085
3371
  if (!doc)
3086
- 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ù') });
3087
3373
  else if (item.action === 'publish') {
3088
3374
  const message = publishProblem(doc);
3089
3375
  if (message)
@@ -3091,7 +3377,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3091
3377
  }
3092
3378
  }
3093
3379
  if (!release.items.length)
3094
- out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
3380
+ out.push({ documentId: '', title: '', message: t.keep('Il rilascio è vuoto') });
3095
3381
  return out;
3096
3382
  };
3097
3383
  /**
@@ -3104,7 +3390,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3104
3390
  .catch((error) => console.error('Materialize: activity not recorded', error));
3105
3391
  const problems = await releaseProblems(release);
3106
3392
  if (problems.length) {
3107
- 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
+ });
3108
3396
  await releases.finish(release.id, 'failed', message);
3109
3397
  await note('release.failed', release.name, { id: release.id, message });
3110
3398
  return false;
@@ -3127,8 +3415,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3127
3415
  }
3128
3416
  }
3129
3417
  catch (error) {
3130
- const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3131
- 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;
3132
3420
  await releases.finish(release.id, 'failed', message);
3133
3421
  await note('release.failed', release.name, { id: release.id, message });
3134
3422
  return false;
@@ -3151,21 +3439,21 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3151
3439
  collection: doc.collection,
3152
3440
  locale: doc.locale,
3153
3441
  status: doc.status,
3154
- problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
3442
+ problem: t.read(problems.find((p) => p.documentId === doc.id)?.message) || null,
3155
3443
  });
3156
3444
  }
3157
- return { ...release, items, ready: problems.length === 0 };
3445
+ return { ...release, error: release.error && t.read(release.error), items, ready: problems.length === 0 };
3158
3446
  };
3159
3447
  const loadRelease = async (id) => {
3160
3448
  const release = await releases.get(id);
3161
3449
  if (!release)
3162
- throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
3450
+ throw new ApiError(404, 'not_found', t('Questo rilascio non esiste'));
3163
3451
  return release;
3164
3452
  };
3165
3453
  const openRelease = async (id) => {
3166
3454
  const release = await loadRelease(id);
3167
3455
  if (release.status === 'published' || release.status === 'cancelled')
3168
- 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'));
3169
3457
  return release;
3170
3458
  };
3171
3459
  app.get('/api/releases', requireUser, async (c) => {
@@ -3201,7 +3489,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3201
3489
  const doc = await loadDocument(c.req.param('documentId'));
3202
3490
  const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
3203
3491
  if (input.action === 'unpublish' && !doc.publishedAt)
3204
- 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'));
3205
3493
  await releases.put(release.id, doc.id, input.action);
3206
3494
  await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
3207
3495
  return c.json(await releaseJson(await loadRelease(release.id)));
@@ -3219,15 +3507,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3219
3507
  return c.json({
3220
3508
  error: {
3221
3509
  code: 'release_not_ready',
3222
- message: 'Alcune pagine non sono pronte: non è uscito niente',
3223
- problems,
3510
+ message: t('Alcune pagine non sono pronte: non è uscito niente'),
3511
+ problems: problems.map((p) => ({ ...p, message: t.read(p.message) })),
3224
3512
  },
3225
3513
  }, 422);
3226
3514
  const user = c.get('user');
3227
3515
  const ok = await releaseOut(release, user);
3228
3516
  const after = await loadRelease(release.id);
3229
3517
  if (!ok)
3230
- 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'));
3231
3519
  return c.json(await releaseJson(after));
3232
3520
  });
3233
3521
  /** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
@@ -3242,9 +3530,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3242
3530
  }
3243
3531
  const at = new Date(input.at);
3244
3532
  if (at.getTime() <= Date.now() + 30_000)
3245
- 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'));
3246
3534
  if (!release.items.length)
3247
- throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
3535
+ throw new ApiError(400, 'empty_release', t('Aggiungi prima qualcosa al rilascio'));
3248
3536
  await releases.schedule(release.id, at, user);
3249
3537
  await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
3250
3538
  return c.json(await releaseJson(await loadRelease(release.id)));
@@ -3413,7 +3701,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3413
3701
  app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
3414
3702
  const found = await newsletter.subscriber(c.req.param('id'));
3415
3703
  if (!found)
3416
- throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
3704
+ throw new ApiError(404, 'not_found', t('Questo iscritto non esiste'));
3417
3705
  await newsletter.removeSubscriber(found.id);
3418
3706
  await record(c, 'newsletter.subscriber_removed', found.email);
3419
3707
  return c.body(null, 204);
@@ -3422,7 +3710,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3422
3710
  const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
3423
3711
  const rows = parseSubscribersCsv(input.csv);
3424
3712
  if (!rows.length)
3425
- 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'));
3426
3714
  const result = await newsletter.importMany(rows);
3427
3715
  await record(c, 'newsletter.imported', '', result);
3428
3716
  return c.json({ found: rows.length, ...result });
@@ -3461,16 +3749,20 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3461
3749
  return c.json(input);
3462
3750
  });
3463
3751
  // ----- panel: newsletters -----
3464
- 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
+ });
3465
3757
  const loadNewsletter = async (id) => {
3466
3758
  const found = await newsletter.get(id);
3467
3759
  if (!found)
3468
- throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
3760
+ throw new ApiError(404, 'not_found', t('Questa newsletter non esiste'));
3469
3761
  return found;
3470
3762
  };
3471
3763
  const editable = (n) => {
3472
3764
  if (n.status === 'sending' || n.status === 'sent')
3473
- 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'));
3474
3766
  };
3475
3767
  const newsletterBody = z.object({
3476
3768
  subject: z.string().trim().min(1).max(200),
@@ -3495,7 +3787,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3495
3787
  app.delete('/api/newsletters/:id', requireUser, async (c) => {
3496
3788
  const found = await loadNewsletter(c.req.param('id'));
3497
3789
  if (found.status === 'sending')
3498
- 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'));
3499
3791
  await newsletter.remove(found.id);
3500
3792
  await record(c, 'newsletter.deleted', found.subject);
3501
3793
  return c.body(null, 204);
@@ -3538,17 +3830,25 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3538
3830
  const readyMail = async () => {
3539
3831
  const mail = await settings.get('mail');
3540
3832
  if (!isConfigured(mail))
3541
- 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'));
3542
3834
  return mail;
3543
3835
  };
3544
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);
3545
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'));
3546
3842
  const found = await loadNewsletter(c.req.param('id'));
3547
3843
  const input = await body(c, z.object({ to: z.email().optional() }));
3548
3844
  const mail = await readyMail();
3549
3845
  await newsletterOrigin(c);
3550
3846
  const message = await composeNewsletter(found, null);
3551
- 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
+ });
3552
3852
  return c.json({ ok: true });
3553
3853
  });
3554
3854
  app.post('/api/newsletters/:id/send', requireUser, async (c) => {
@@ -3558,7 +3858,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3558
3858
  await newsletterOrigin(c);
3559
3859
  const { counts } = await newsletter.subscribers({ limit: 1 });
3560
3860
  if (!counts.active)
3561
- 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'));
3562
3862
  await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
3563
3863
  await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
3564
3864
  void runNewsletters();
@@ -3574,7 +3874,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3574
3874
  }
3575
3875
  const at = new Date(input.at);
3576
3876
  if (at.getTime() <= Date.now() + 30_000)
3577
- 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'));
3578
3878
  await readyMail();
3579
3879
  await newsletterOrigin(c);
3580
3880
  await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
@@ -3624,7 +3924,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3624
3924
  const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
3625
3925
  const user = c.get('user');
3626
3926
  if (!allowAi(user.id))
3627
- 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'));
3628
3928
  const ai = await readyAi();
3629
3929
  const config = ctx.config();
3630
3930
  const lang = config.locales.default;
@@ -3654,7 +3954,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3654
3954
  items.sort((a, b) => b.date - a.date);
3655
3955
  const latest = items.slice(0, input.count);
3656
3956
  if (!latest.length)
3657
- 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'));
3658
3958
  const voice = await voiceText();
3659
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": "..."}?}]}.`;
3660
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')}`;
@@ -3664,7 +3964,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3664
3964
  parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
3665
3965
  }
3666
3966
  catch {
3667
- 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'));
3668
3968
  }
3669
3969
  const known = new Set(latest.map((a) => a.url));
3670
3970
  const blocks = [];
@@ -3700,7 +4000,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3700
4000
  for (const n of await newsletter.due(now)) {
3701
4001
  const mail = await settings.get('mail');
3702
4002
  if (!isConfigured(mail)) {
3703
- 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') });
3704
4004
  continue;
3705
4005
  }
3706
4006
  if (n.status === 'scheduled')
@@ -3713,7 +4013,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3713
4013
  for (const item of batch) {
3714
4014
  const who = await newsletter.subscriber(item.subscriberId);
3715
4015
  if (!who || who.status !== 'active') {
3716
- await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
4016
+ await newsletter.markSend(item.id, 'failed', t.keep('Non più iscritto'));
3717
4017
  continue;
3718
4018
  }
3719
4019
  try {
@@ -3723,7 +4023,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3723
4023
  sent++;
3724
4024
  }
3725
4025
  catch (error) {
3726
- 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'));
3727
4027
  }
3728
4028
  if (pause)
3729
4029
  await new Promise((r) => setTimeout(r, pause));
@@ -3733,7 +4033,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3733
4033
  const allFailed = stats.total > 0 && stats.sent === 0;
3734
4034
  await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
3735
4035
  sentAt: new Date(),
3736
- error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
4036
+ error: allFailed ? t.keep('Nessun invio riuscito: controlla Impostazioni → Email') : null,
3737
4037
  });
3738
4038
  }
3739
4039
  // Signups never confirmed are dropped after a week.
@@ -3766,9 +4066,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3766
4066
  const existing = await loadDocument(c.req.param('id'));
3767
4067
  const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
3768
4068
  if (!ctx.preview)
3769
- 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'));
3770
4070
  if (!collection(existing.collection).url)
3771
- 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'));
3772
4072
  const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
3773
4073
  const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
3774
4074
  await record(c, 'document.preview_shared', existing.title, {
@@ -3781,7 +4081,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3781
4081
  app.delete('/api/previews/:id', requireUser, async (c) => {
3782
4082
  const link = await previewLinks.get(c.req.param('id'));
3783
4083
  if (!link)
3784
- throw new ApiError(404, 'not_found', 'Link non trovato');
4084
+ throw new ApiError(404, 'not_found', t('Link non trovato'));
3785
4085
  const doc = await content.get(link.documentId);
3786
4086
  await previewLinks.revoke(link.id);
3787
4087
  await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
@@ -3811,6 +4111,16 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3811
4111
  });
3812
4112
  app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
3813
4113
  const existing = await loadDocument(c.req.param('id'));
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]);
3814
4124
  const doc = await content.restore(existing.id, c.req.param('revision'), c.get('user').id);
3815
4125
  if (!doc)
3816
4126
  throw new ApiError(404, 'not_found', 'This version does not exist');
@@ -3832,11 +4142,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3832
4142
  });
3833
4143
  app.put('/api/locales', requireUser, requireSession, requireAdmin, async (c) => {
3834
4144
  const input = await body(c, z.object({
3835
- default: z.string().refine(isLocaleCode, 'Codice lingua non valido'),
3836
- 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),
3837
4150
  }));
3838
4151
  if (!input.available.includes(input.default))
3839
- 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'));
3840
4153
  const value = { default: input.default, available: [...new Set(input.available)] };
3841
4154
  await settings.set('locales', value, c.get('user').id);
3842
4155
  languages = value;
@@ -3874,11 +4187,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3874
4187
  const lang = locale(input.locale);
3875
4188
  const group = await content.translations(doc.id);
3876
4189
  if (group.some((t) => t.locale === lang))
3877
- 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'));
3878
4191
  if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0)
3879
- 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 }));
3880
4193
  if (input.translate && !allowAi(c.get('user').id))
3881
- 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'));
3882
4195
  const data = structuredClone(doc.data);
3883
4196
  if (input.translate) {
3884
4197
  // The address is translated too ("chi-siamo" → "about-us"), the home page keeps its own.
@@ -3913,7 +4226,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3913
4226
  const input = await body(c, z.object({ translate: z.boolean().default(false) }));
3914
4227
  const doc = await loadDocument(c.req.param('id'));
3915
4228
  if (!doc.sourceId)
3916
- throw new ApiError(400, 'not_a_translation', 'Questa è la versione originale');
4229
+ throw new ApiError(400, 'not_a_translation', t('Questa è la versione originale'));
3917
4230
  const original = await loadDocument(doc.sourceId);
3918
4231
  const def = collection(doc.collection);
3919
4232
  const data = structuredClone(doc.data);
@@ -3929,7 +4242,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3929
4242
  }
3930
4243
  if (input.translate && fresh.size) {
3931
4244
  if (!allowAi(c.get('user').id))
3932
- 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'));
3933
4246
  const ai = await readyAi();
3934
4247
  for (const [name, f] of Object.entries(def.fields)) {
3935
4248
  if (f.kind !== 'page' || !Array.isArray(data[name]))
@@ -3967,7 +4280,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3967
4280
  wpJobs.delete(k);
3968
4281
  };
3969
4282
  /** A WordPress site is read from its address (REST API) or from its export file. */
3970
- 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) => {
3971
4289
  forget();
3972
4290
  const isForm = (c.req.header('content-type') ?? '').includes('multipart/form-data');
3973
4291
  let site;
@@ -4001,7 +4319,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
4001
4319
  if (error instanceof WordPressError)
4002
4320
  throw new ApiError(422, 'wordpress_unreadable', error.message);
4003
4321
  if (error instanceof RemoteError)
4004
- 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 }));
4005
4323
  throw error;
4006
4324
  }
4007
4325
  const token = randomBytes(16).toString('hex');
@@ -4058,12 +4376,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
4058
4376
  const relayoutPage = async (ai, id, user, credentials) => {
4059
4377
  const doc = await content.get(id);
4060
4378
  if (!doc)
4061
- return 'la pagina non c’è più';
4379
+ return t('la pagina non c’è più');
4062
4380
  const def = collection(doc.collection);
4063
4381
  const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
4064
4382
  const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
4065
4383
  if (!field || !before?.length)
4066
- return 'la pagina è vuota';
4384
+ return t('la pagina è vuota');
4067
4385
  const page = { nodes: before };
4068
4386
  const call = apiCaller(app.fetch, credentials);
4069
4387
  const getSchema = () => call('GET', '/api/schema');
@@ -4155,15 +4473,15 @@ When done, reply with one short sentence.`;
4155
4473
  }
4156
4474
  catch (error) {
4157
4475
  return {
4158
- error: error instanceof RemoteError && /too large/.test(error.message)
4159
- ? 'troppo grandi (oltre 25 MB)'
4160
- : 'non raggiungibili',
4476
+ error: error instanceof RemoteError && error.reason === 'too_large'
4477
+ ? t('troppo grandi (oltre 25 MB)')
4478
+ : t('non raggiungibili'),
4161
4479
  };
4162
4480
  }
4163
4481
  if (res.status !== 200)
4164
- return { error: `non trovate sul sito (${res.status})` };
4482
+ return { error: t('non trovate sul sito ({status})', { status: res.status }) };
4165
4483
  if (/svg/.test(res.type) || /\.svg$/i.test(res.name))
4166
- 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') };
4167
4485
  try {
4168
4486
  const saved = await files.save(res.name, res.bytes);
4169
4487
  const asset = await mediaStore.create({ ...saved, filename: res.name.slice(0, 200), alt, createdBy: user.id });
@@ -4177,7 +4495,7 @@ When done, reply with one short sentence.`;
4177
4495
  return media;
4178
4496
  }
4179
4497
  catch {
4180
- return { error: 'in un formato che la libreria non legge' };
4498
+ return { error: t('in un formato che la libreria non legge') };
4181
4499
  }
4182
4500
  },
4183
4501
  fetchPage: async (url) => {
@@ -4394,7 +4712,7 @@ When done, reply with one short sentence.`;
4394
4712
  // ---------- public delivery API: published content only ----------
4395
4713
  const publicDocument = (doc) => {
4396
4714
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
4397
- const data = doc.publishedData ?? {};
4715
+ const data = withoutFormSettings(doc.publishedData ?? {});
4398
4716
  return {
4399
4717
  id: doc.id,
4400
4718
  slug: doc.slug,