@materializecms/server 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Pros
4
4
 
5
5
  ## Prossima versione
6
6
 
7
+ ## 0.5.0 — 2026-11-05
8
+
9
+ - **Nuovo logo**: una M fatta di blocchi che passano da trasparenti a pieni, perché i contenuti prendono forma. Il nome è in Geist. Lo trovi nel pannello, nella favicon, nella pagina di accesso e nel README; i file sono in `docs/assets`.
10
+ - **Pubblicazione programmata**: scegli quando una pagina o un articolo esce, e se vuoi quando torna bozza da solo (offerte, eventi). Dal pulsante con il calendario accanto a «Pubblica», con scorciatoie come «Domani alle 9». Esce la bozza com'è in quel momento; i campi mancanti vengono segnalati subito, e se al momento di uscire qualcosa non va la pagina resta bozza e il pannello dice perché. Le date compaiono anche negli elenchi.
11
+ - **Link di anteprima da condividere**: un link segreto per far vedere una bozza a un cliente o a un collega senza account, con il design vero del sito. Scade dopo 1, 7 o 30 giorni o quando lo revochi, segue le modifiche, non finisce su Google e mostra un'etichetta «Anteprima». Da «Altre azioni» → «Condividi anteprima».
12
+ - **Pronto per le ricerche AI**: il sito pubblica `llms.txt`, l'indice per ChatGPT, Claude, Perplexity e gli altri assistenti, e una copia in Markdown di ogni pagina (per esempio `/chi-siamo.md`), più facile da leggere e citare. Si spegne in Impostazioni → SEO.
13
+ - **Feed RSS** su `/rss.xml` con gli ultimi articoli, annunciato in ogni pagina; in Impostazioni → SEO si scelgono le raccolte.
14
+ - **Blocco dei crawler AI più preciso**: blocca solo chi addestra modelli; le ricerche AI (ChatGPT Search, Claude, Perplexity) restano libere, perché portano visitatori. Prima PerplexityBot veniva bloccato.
15
+ - **Controllo di accessibilità e prestazioni**: la scheda SEO dell'editor diventa **Controlli**, con tre gruppi. Accessibilità: contrasto dei testi misurato sulla pagina vera (WCAG), immagini senza descrizione, pulsanti e icone senza nome, campi senza etichetta, link poco chiari, titoli saltati, video senza pausa. Prestazioni: file da altri siti, GIF e video pesanti, video senza copertina, troppe scene 3D, script esterni, troppi caratteri. Prima di pubblicare, i problemi seri vengono elencati.
16
+ - **Moduli più robusti**: antispam senza CAPTCHA (lo spam va in una cartella recuperabile), controlli dei campi anche sul server con il motivo accanto al campo, **file allegati** privati, destinatari per modulo, **risposta automatica** a chi scrive, **webhook** firmato per Zapier, Make e n8n. In Impostazioni → Messaggi: filtri per modulo, da leggere e spam, ricerca, **esportazione CSV** e cancellazione automatica dei messaggi vecchi. La casella da spuntare può aprire l'informativa privacy.
17
+ - Una pagina riportata com'è sul sito (per esempio con ⌘Z) non risulta più «Modifiche da pubblicare».
18
+
7
19
  ## 0.4.0 — 2026-11-03
8
20
 
9
21
  - **Immagini di riferimento nell'assistente**: allega, incolla (⌘V) o trascina nella chat lo screenshot di un sito che ti piace, un mockup o uno schizzo, e l'assistente ne riprende impaginazione e atmosfera con le sezioni e lo stile del tuo sito. Fino a 4 immagini per messaggio; non vanno nella libreria e non finiscono sulla pagina.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@anthropic-ai/sdk": "1.3.0",
22
22
  "@hono/node-server": "2.1.5",
23
- "@materializecms/admin": "0.4.0",
24
- "@materializecms/core": "0.4.0",
25
- "@materializecms/db": "0.4.0",
23
+ "@materializecms/admin": "0.5.0",
24
+ "@materializecms/core": "0.5.0",
25
+ "@materializecms/db": "0.5.0",
26
26
  "@modelcontextprotocol/sdk": "1.32.2",
27
27
  "hono": "4.13.14",
28
28
  "htmlparser2": "12.0.0",
package/src/app.d.ts CHANGED
@@ -29,5 +29,7 @@ export interface ServerContext {
29
29
  */
30
30
  setupToken?: string;
31
31
  }
32
- export declare function createApp(base: ServerContext): Hono<Env, import("hono/types").BlankSchema, "/">;
32
+ export declare function createApp(base: ServerContext): Hono<Env, import("hono/types").BlankSchema, "/"> & {
33
+ runSchedules: (now?: Date) => Promise<number>;
34
+ };
33
35
  export {};
package/src/app.js CHANGED
@@ -9,11 +9,11 @@ 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
11
  import { senderDomain } from '@materializecms/core/mail';
12
- import { normalizeTheme, sameDesign } from '@materializecms/core/page';
12
+ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDesign, walk, } from '@materializecms/core/page';
13
13
  import { addMove, normalizePath } from '@materializecms/core/seo';
14
14
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
15
15
  import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
16
- import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
16
+ import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createPreviewLinkStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
17
17
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
18
18
  import { Hono } from 'hono';
19
19
  import { bodyLimit } from 'hono/body-limit';
@@ -28,6 +28,7 @@ import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts
28
28
  import { folderEntries, tarGz } from './archive.js';
29
29
  import { createBackups, DEFAULT_BACKUPS } from './backups.js';
30
30
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
31
+ import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
31
32
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
32
33
  import { createMcpServer } from './mcp.js';
33
34
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
@@ -61,7 +62,10 @@ const FORGOT_THRESHOLD = 3;
61
62
  const FORGOT_IP_THRESHOLD = 10;
62
63
  /** Where people open their one-time link: the admin's welcome screen. */
63
64
  const linkPath = (token) => `/admin/_invite/${token}`;
64
- /** The custom code blocks in a document: it runs on the public site, so only admins may change it. */
65
+ /**
66
+ * The custom code blocks in a document (it runs on the public site) and where its forms send
67
+ * their messages: only admins may change them.
68
+ */
65
69
  function embedsOf(value, out = []) {
66
70
  if (Array.isArray(value))
67
71
  for (const v of value)
@@ -70,6 +74,10 @@ function embedsOf(value, out = []) {
70
74
  const node = value;
71
75
  if (node.type === 'embed' && typeof node.id === 'string')
72
76
  out.push(`${node.id}:${String(node.html ?? '')}`);
77
+ // Where a form's messages go: someone who only edits pages must not divert them.
78
+ const form = node.form;
79
+ if (form && typeof node.id === 'string' && (form.to || form.webhook))
80
+ out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}`);
73
81
  for (const v of Object.values(node))
74
82
  if (v && typeof v === 'object')
75
83
  embedsOf(v, out);
@@ -132,6 +140,8 @@ const siteSeoSchema = z.object({
132
140
  })
133
141
  .optional(),
134
142
  blockAi: z.boolean().optional(),
143
+ llms: z.boolean().optional(),
144
+ feed: z.array(z.string().max(80)).max(50).optional(),
135
145
  disallow: z.array(z.string().trim().max(300)).max(100).optional(),
136
146
  google: short,
137
147
  bing: short,
@@ -179,6 +189,8 @@ export function createApp(base) {
179
189
  const submissions = createSubmissionStore(ctx.database.db);
180
190
  const comments = createCommentStore(ctx.database.db);
181
191
  const activityLog = createActivityStore(ctx.database.db);
192
+ const schedules = createScheduleStore(ctx.database.db);
193
+ const previewLinks = createPreviewLinkStore(ctx.database.db);
182
194
  const backups = createBackups(ctx.database, () => ctx.config().media.dir);
183
195
  const backupSettings = async () => ({
184
196
  ...DEFAULT_BACKUPS,
@@ -308,12 +320,20 @@ export function createApp(base) {
308
320
  const files = createMediaFiles(ctx.config().media.dir);
309
321
  // Fonts are cached next to the media library (.materialize/fonts by default).
310
322
  const fonts = createFonts(join(dirname(resolve(ctx.config().media.dir)), 'fonts'));
323
+ // Files sent with the site's forms: next to the database, never served by the public site.
324
+ const uploadsDir = join(dirname(resolve(ctx.config().media.dir)), 'uploads');
325
+ let formCache = null;
326
+ /** The live site changed: rebuild it, and read its forms again. */
327
+ const sitePublished = () => {
328
+ formCache = null;
329
+ ctx.onPublish?.();
330
+ };
311
331
  const app = new Hono();
312
332
  const guardEmbeds = (user, before, after) => {
313
333
  if (user.role === 'admin')
314
334
  return;
315
335
  if (JSON.stringify(embedsOf(before).sort()) !== JSON.stringify(embedsOf(after).sort()))
316
- throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può aggiungere o modificare il codice personalizzato');
336
+ throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli');
317
337
  };
318
338
  /** Editors who may only change contents (Persone → Redattori): words, links and images, not the layout. */
319
339
  const contentOnly = async (user) => user.role !== 'admin' && !!(await settings.get('editorial'))?.editorsContentOnly;
@@ -432,13 +452,15 @@ export function createApp(base) {
432
452
  return `/${locale}${path === '/' ? '' : path}`;
433
453
  };
434
454
  /** A document as the admin sees it, with what still needs fixing before it can be published. */
435
- const toJson = (doc) => {
455
+ const toJson = async (doc) => {
436
456
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
437
457
  const check = def ? validate(def, prepare(def, doc.data)) : null;
438
458
  return {
439
459
  ...doc,
440
460
  url: def?.url ? def.url(doc.data) : null,
441
461
  issues: check && !check.ok ? check.issues : [],
462
+ /** When it goes on or off the site by itself, and the last scheduled action that failed. */
463
+ schedule: await schedules.forDocument(doc.id),
442
464
  };
443
465
  };
444
466
  /**
@@ -628,7 +650,7 @@ export function createApp(base) {
628
650
  : await content.create({ collection: def.name, locale: lang, ...draft });
629
651
  if (doc)
630
652
  await content.publish(doc.id);
631
- ctx.onPublish?.();
653
+ sitePublished();
632
654
  };
633
655
  /** The site's name as set in the panel (the settings singleton, published), else the config's. */
634
656
  const siteName = async () => {
@@ -1102,6 +1124,7 @@ export function createApp(base) {
1102
1124
  const mediaDir = resolve(ctx.config().media.dir);
1103
1125
  entries.push(...(await folderEntries(mediaDir, 'media')));
1104
1126
  entries.push(...(await folderEntries(join(dirname(mediaDir), 'fonts'), 'fonts')));
1127
+ entries.push(...(await folderEntries(uploadsDir, 'uploads')));
1105
1128
  const manifest = join(work, 'materialize.json');
1106
1129
  await writeFile(manifest, JSON.stringify({
1107
1130
  site: ctx.config().site.name,
@@ -1528,7 +1551,7 @@ export function createApp(base) {
1528
1551
  const input = await body(c, z.object({ theme: z.record(z.string(), z.unknown()) }));
1529
1552
  const theme = normalizeTheme(input.theme);
1530
1553
  await settings.set('theme', theme, c.get('user').id);
1531
- ctx.onPublish?.();
1554
+ sitePublished();
1532
1555
  await record(c, 'settings.theme');
1533
1556
  return c.json({ theme });
1534
1557
  });
@@ -1588,7 +1611,7 @@ export function createApp(base) {
1588
1611
  .filter(([id]) => main[id])
1589
1612
  .map(([id, n]) => [id, n]));
1590
1613
  await settings.set(`globals:${lang}`, words, c.get('user').id);
1591
- ctx.onPublish?.();
1614
+ sitePublished();
1592
1615
  return c.json({ globals: await globalsIn(lang) });
1593
1616
  }
1594
1617
  const before = (await settings.get('globals')) ?? {};
@@ -1603,7 +1626,7 @@ export function createApp(base) {
1603
1626
  const ids = [...new Set([...Object.keys(before), ...Object.keys(input.globals)])].sort();
1604
1627
  await guardDesign(c.get('user'), ids.map((id) => before[id] ?? { id, type: 'frame' }), ids.map((id) => input.globals[id] ?? { id, type: 'frame' }));
1605
1628
  await settings.set('globals', input.globals, c.get('user').id);
1606
- ctx.onPublish?.();
1629
+ sitePublished();
1607
1630
  return c.json({ globals: input.globals });
1608
1631
  });
1609
1632
  // ---------- comments and presence ----------
@@ -1982,59 +2005,238 @@ When done, say in one or two short sentences what you changed. Reply in the pers
1982
2005
  }
1983
2006
  });
1984
2007
  });
1985
- app.post('/api/forms/:form', async (c) => {
2008
+ // ---------- forms ----------
2009
+ /**
2010
+ * Every form on the published site, by id, as the server checks it: canvas pages with their
2011
+ * shared sections and components expanded, like the site renders them. Kept a minute.
2012
+ */
2013
+ const publishedForms = async () => {
2014
+ if (formCache && Date.now() - formCache.at < 60_000)
2015
+ return formCache.forms;
2016
+ const forms = new Map();
2017
+ const globals = (await settings.get('globals')) ?? {};
2018
+ const components = (await settings.get('components')) ?? {};
2019
+ const collect = (nodes) => walk(expandComponents(expandGlobals(nodes, globals), components), (n) => {
2020
+ const def = formDefinition(n);
2021
+ if (def && !forms.has(def.id))
2022
+ forms.set(def.id, def);
2023
+ });
2024
+ for (const def of ctx.config().collections) {
2025
+ const pageFields = Object.entries(def.fields)
2026
+ .filter(([, f]) => f.kind === 'page')
2027
+ .map(([name]) => name);
2028
+ if (!pageFields.length)
2029
+ continue;
2030
+ for (const doc of await content.published(def.name)) {
2031
+ for (const name of pageFields) {
2032
+ const nodes = doc.publishedData?.[name];
2033
+ if (Array.isArray(nodes))
2034
+ collect(nodes);
2035
+ }
2036
+ }
2037
+ }
2038
+ collect(Object.values(globals));
2039
+ formCache = { at: Date.now(), forms };
2040
+ return forms;
2041
+ };
2042
+ const formsSettings = async () => (await settings.get('forms')) ?? {};
2043
+ /** The secret that signs webhook calls, made the first time it is needed. */
2044
+ const webhookSecret = async () => {
2045
+ const current = await formsSettings();
2046
+ if (current.webhookSecret)
2047
+ return current.webhookSecret;
2048
+ const secret = `whsec_${randomBytes(24).toString('base64url')}`;
2049
+ await settings.set('forms', { ...current, webhookSecret: secret });
2050
+ return secret;
2051
+ };
2052
+ app.post('/api/forms/:form', bodyLimit({
2053
+ maxSize: 25 * 1024 * 1024,
2054
+ onError: () => {
2055
+ throw new ApiError(413, 'file_too_large', 'Gli allegati sono troppo grandi');
2056
+ },
2057
+ }), async (c) => {
1986
2058
  const form = c.req.param('form').slice(0, 80);
1987
2059
  const ip = clientIp(c);
1988
2060
  if (!allowForm(`${ip}:${form}`))
1989
2061
  throw new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
1990
- const isJson = (c.req.header('content-type') ?? '').includes('application/json');
2062
+ const type = c.req.header('content-type') ?? '';
2063
+ const isJson = type.includes('application/json');
2064
+ const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
1991
2065
  const raw = (isJson ? await c.req.json().catch(() => ({})) : await c.req.parseBody());
1992
2066
  const page = typeof raw._page === 'string' ? raw._page.slice(0, 300) : (c.req.header('referer') ?? '').slice(0, 300);
1993
- // A field people never see: bots fill it in.
1994
- const trap = typeof raw._gotcha === 'string' && raw._gotcha.length > 0;
1995
- const data = {};
1996
- for (const [k, v] of Object.entries(raw)) {
1997
- if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
1998
- continue;
1999
- data[k.slice(0, 80)] = v.slice(0, 5000);
2000
- }
2001
- if (!trap && Object.values(data).some((v) => v.trim())) {
2002
- const saved = await submissions.add({ form, page, data });
2067
+ const back = (c.req.header('referer') ?? '/').split('#')[0];
2068
+ // A field people never see: bots fill it in. They get the same answer as everyone.
2069
+ if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
2070
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2071
+ const def = (await publishedForms()).get(form) ?? null;
2072
+ const { data, files, issues } = await readSubmission(raw, def);
2073
+ if (issues.length) {
2074
+ if (!wantsJson)
2075
+ return c.redirect(`${back}#non-inviato`, 303);
2076
+ return c.json({ error: { code: 'invalid', message: 'Controlla i campi evidenziati', issues } }, 422);
2077
+ }
2078
+ if (!Object.values(data).some((v) => v.trim()) && !files.length)
2079
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2080
+ const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
2081
+ const fingerprint = fingerprintOf(form, data);
2082
+ const spam = spamReason(data, elapsed) ??
2083
+ ((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
2084
+ const formName = def?.name?.trim() || def?.subject?.trim() || '';
2085
+ const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2086
+ if (files.length)
2087
+ await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
2088
+ // Spam stays in the panel, recoverable, but nobody is notified.
2089
+ if (!spam) {
2003
2090
  const mail = await settings.get('mail');
2004
- if (isConfigured(mail) && mail.to) {
2005
- const replyTo = Object.entries(data).find(([k, v]) => /mail/i.test(k) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
2091
+ const to = (def?.to || mail?.to || '').trim();
2092
+ 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];
2093
+ if (isConfigured(mail) && to) {
2094
+ const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
2095
+ const attached = files.length
2096
+ ? `\n\nAllegati (${files.map((f) => f.name).join(', ')}): li trovi in Impostazioni → Messaggi.`
2097
+ : '';
2006
2098
  sendMail(mail, {
2007
- to: mail.to,
2008
- subject: `Nuovo messaggio dal sito${typeof raw._subject === 'string' ? `: ${raw._subject.slice(0, 120)}` : ''}`,
2009
- text: submissionText(page, data),
2010
- replyTo,
2099
+ to,
2100
+ subject: `Nuovo messaggio dal sito${subject ? `: ${subject}` : ''}`,
2101
+ text: submissionText(page, data) + attached,
2102
+ replyTo: sender,
2011
2103
  })
2012
2104
  .then(() => submissions.markMailed(saved.id))
2013
2105
  .catch((error) => console.error('Materialize: email not sent', error));
2106
+ // An answer to whoever wrote: the site's own words only, never what was sent.
2107
+ if (sender && def?.autoReply?.text?.trim())
2108
+ sendMail(mail, {
2109
+ to: sender,
2110
+ subject: def.autoReply.subject?.trim() || 'Abbiamo ricevuto il tuo messaggio',
2111
+ text: def.autoReply.text,
2112
+ replyTo: to.split(',')[0]?.trim(),
2113
+ }).catch((error) => console.error('Materialize: automatic reply not sent', error));
2114
+ }
2115
+ if (def?.webhook) {
2116
+ const payload = {
2117
+ id: saved.id,
2118
+ form: { id: form, name: formName },
2119
+ page,
2120
+ data,
2121
+ files: files.map((f) => ({ field: f.field, name: f.name, size: f.bytes.length })),
2122
+ createdAt: saved.createdAt.toISOString(),
2123
+ };
2124
+ void webhookSecret()
2125
+ .then((secret) => sendWebhook(def.webhook, payload, secret))
2126
+ .then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
2127
+ .catch((error) => console.error('Materialize: webhook not sent', error));
2014
2128
  }
2015
2129
  }
2016
2130
  // Without JavaScript the browser posted the form itself: back to the page, marked as sent.
2017
- if (!isJson) {
2018
- const back = (c.req.header('referer') ?? '/').split('#')[0];
2019
- return c.redirect(`${back}#inviato`, 303);
2020
- }
2021
- return c.json({ ok: true });
2131
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2132
+ });
2133
+ const filterOf = (c) => ({
2134
+ form: c.req.query('form') || undefined,
2135
+ status: ['unread', 'spam'].find((s) => s === c.req.query('status')),
2136
+ q: c.req.query('q')?.slice(0, 200) || undefined,
2022
2137
  });
2023
2138
  app.get('/api/submissions', requireUser, async (c) => {
2024
2139
  const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
2025
2140
  const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
2026
- return c.json(await submissions.list(limit, offset));
2141
+ return c.json({
2142
+ ...(await submissions.list(limit, offset, filterOf(c))),
2143
+ forms: await submissions.forms(),
2144
+ });
2145
+ });
2146
+ /** The messages as a spreadsheet (one column per field), with the filters of the list. */
2147
+ app.get('/api/submissions/export.csv', requireUser, async (c) => {
2148
+ const { items } = await submissions.list(10_000, 0, filterOf(c));
2149
+ const fields = [...new Set(items.flatMap((m) => Object.keys(m.data)))];
2150
+ const cell = (v) => {
2151
+ // A leading =, +, - or @ would run as a formula in a spreadsheet.
2152
+ const safe = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
2153
+ return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
2154
+ };
2155
+ const rows = [
2156
+ ['Data', 'Modulo', 'Pagina', ...fields],
2157
+ ...items.map((m) => [
2158
+ m.createdAt.toISOString(),
2159
+ m.formName || m.form,
2160
+ m.page,
2161
+ ...fields.map((f) => m.data[f] ?? ''),
2162
+ ]),
2163
+ ];
2164
+ await record(c, 'message.exported', '', { count: items.length });
2165
+ // With the byte order mark, Excel reads accented letters right.
2166
+ return new Response(`${rows.map((r) => r.map(cell).join(',')).join('\r\n')}\r\n`, {
2167
+ headers: {
2168
+ 'content-type': 'text/csv; charset=utf-8',
2169
+ 'content-disposition': `attachment; filename="messaggi-${new Date().toISOString().slice(0, 10)}.csv"`,
2170
+ 'cache-control': 'no-store',
2171
+ },
2172
+ });
2027
2173
  });
2028
2174
  app.patch('/api/submissions/:id', requireUser, async (c) => {
2029
- const input = await body(c, z.object({ read: z.boolean() }));
2030
- await submissions.markRead(c.req.param('id'), input.read);
2175
+ const input = await body(c, z.object({ read: z.boolean().optional(), spam: z.boolean().optional() }));
2176
+ const id = c.req.param('id');
2177
+ if (input.read !== undefined)
2178
+ await submissions.markRead(id, input.read);
2179
+ if (input.spam !== undefined)
2180
+ await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
2031
2181
  return c.json({ ok: true });
2032
2182
  });
2183
+ /** A file sent with a message: always downloaded, never opened in the panel's own page. */
2184
+ app.get('/api/submissions/:id/files/:n', requireUser, async (c) => {
2185
+ const message = await submissions.get(c.req.param('id'));
2186
+ const file = message?.files[Number(c.req.param('n'))];
2187
+ if (!message || !file)
2188
+ throw new ApiError(404, 'not_found', 'File non trovato');
2189
+ const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
2190
+ if (!bytes)
2191
+ throw new ApiError(404, 'not_found', 'Il file non c’è più');
2192
+ return new Response(bytes, {
2193
+ headers: {
2194
+ 'content-type': 'application/octet-stream',
2195
+ 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
2196
+ 'x-content-type-options': 'nosniff',
2197
+ 'cache-control': 'no-store',
2198
+ },
2199
+ });
2200
+ });
2033
2201
  app.delete('/api/submissions/:id', requireUser, requireAdmin, async (c) => {
2034
2202
  await submissions.remove(c.req.param('id'));
2203
+ await removeFiles(uploadsDir, c.req.param('id'));
2035
2204
  await record(c, 'message.deleted');
2036
2205
  return c.body(null, 204);
2037
2206
  });
2207
+ /** How long messages are kept, and the key to check webhook calls (admins only see the key). */
2208
+ app.get('/api/forms/settings', requireUser, async (c) => {
2209
+ const current = await formsSettings();
2210
+ const admin = c.get('user').role === 'admin';
2211
+ return c.json({
2212
+ keepMonths: current.keepMonths ?? null,
2213
+ webhookSecret: admin ? await webhookSecret() : null,
2214
+ });
2215
+ });
2216
+ app.put('/api/forms/settings', requireUser, requireAdmin, async (c) => {
2217
+ const input = await body(c, z.object({ keepMonths: z.number().int().min(1).max(120).nullable() }));
2218
+ const current = await formsSettings();
2219
+ await settings.set('forms', { ...current, keepMonths: input.keepMonths ?? undefined }, c.get('user').id);
2220
+ await record(c, 'settings.forms', '', { keepMonths: input.keepMonths });
2221
+ void pruneMessages();
2222
+ return c.json({ keepMonths: input.keepMonths });
2223
+ });
2224
+ /** Deletes the messages older than the kept months, with their files. */
2225
+ const pruneMessages = async () => {
2226
+ const months = (await formsSettings()).keepMonths;
2227
+ if (!months)
2228
+ return 0;
2229
+ const before = new Date();
2230
+ before.setMonth(before.getMonth() - months);
2231
+ const old = await submissions.olderThan(before);
2232
+ for (const m of old) {
2233
+ await submissions.remove(m.id);
2234
+ await removeFiles(uploadsDir, m.id);
2235
+ }
2236
+ return old.length;
2237
+ };
2238
+ setTimeout(() => void pruneMessages().catch(() => { }), 10_000).unref?.();
2239
+ setInterval(() => void pruneMessages().catch(() => { }), 6 * 3_600_000).unref?.();
2038
2240
  // Who may change what: editors limited to contents, if the admins want so.
2039
2241
  app.get('/api/editorial', requireUser, async (c) => c.json({ editorial: (await settings.get('editorial')) ?? {} }));
2040
2242
  app.put('/api/editorial', requireUser, requireSession, requireAdmin, async (c) => {
@@ -2087,7 +2289,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2087
2289
  }),
2088
2290
  }));
2089
2291
  await settings.set('tracking', input.tracking, c.get('user').id);
2090
- ctx.onPublish?.();
2292
+ sitePublished();
2091
2293
  await record(c, 'settings.tracking', '', {
2092
2294
  services: trackingBlocks(input.tracking).map((b) => b.name),
2093
2295
  });
@@ -2098,7 +2300,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2098
2300
  app.put('/api/seo', requireUser, requireAdmin, async (c) => {
2099
2301
  const input = await body(c, z.object({ seo: siteSeoSchema }));
2100
2302
  await settings.set('seo', input.seo, c.get('user').id);
2101
- ctx.onPublish?.();
2303
+ sitePublished();
2102
2304
  await record(c, 'settings.seo');
2103
2305
  return c.json({ seo: input.seo });
2104
2306
  });
@@ -2124,7 +2326,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2124
2326
  .map((r) => ({ ...r, from: normalizePath(r.from), to: /^https?:\/\//.test(r.to) ? r.to : normalizePath(r.to) }))
2125
2327
  .filter((r, i, all) => r.from !== r.to && all.findIndex((o) => o.from === r.from) === i);
2126
2328
  await settings.set('redirects', redirects, c.get('user').id);
2127
- ctx.onPublish?.();
2329
+ sitePublished();
2128
2330
  await record(c, 'settings.redirects', '', { count: redirects.length });
2129
2331
  return c.json({ redirects });
2130
2332
  });
@@ -2133,7 +2335,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2133
2335
  app.put('/api/templates', requireUser, async (c) => {
2134
2336
  const input = await body(c, z.object({ templates: z.record(z.string(), z.string()) }));
2135
2337
  await settings.set('templates', input.templates, c.get('user').id);
2136
- ctx.onPublish?.();
2338
+ sitePublished();
2137
2339
  return c.json({ templates: input.templates });
2138
2340
  });
2139
2341
  // Reusable components with their variants.
@@ -2145,7 +2347,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2145
2347
  if (JSON.stringify(before) !== JSON.stringify(input.components) && (await contentOnly(c.get('user'))))
2146
2348
  throw designLocked();
2147
2349
  await settings.set('components', input.components, c.get('user').id);
2148
- ctx.onPublish?.();
2350
+ sitePublished();
2149
2351
  return c.json({ components: input.components });
2150
2352
  });
2151
2353
  // Entries with their data, for lists bound to a collection on the canvas.
@@ -2233,7 +2435,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2233
2435
  return null;
2234
2436
  }
2235
2437
  };
2236
- return c.json({ ...result, items: result.items.map((i) => ({ ...i, url: urlOf(i.slug) })) });
2438
+ // What goes on or off the site by itself, for a label in the list.
2439
+ const pending = await schedules.pending();
2440
+ const when = (id, action) => pending.find((p) => p.documentId === id && p.action === action)?.runAt ?? null;
2441
+ return c.json({
2442
+ ...result,
2443
+ items: result.items.map((i) => ({
2444
+ ...i,
2445
+ url: urlOf(i.slug),
2446
+ publishAt: when(i.id, 'publish'),
2447
+ unpublishAt: when(i.id, 'unpublish'),
2448
+ })),
2449
+ });
2237
2450
  });
2238
2451
  app.post('/api/collections/:name', requireUser, async (c) => {
2239
2452
  const def = collection(c.req.param('name'));
@@ -2246,7 +2459,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2246
2459
  guardEmbeds(c.get('user'), null, draft.data);
2247
2460
  const doc = await slugGuard(() => content.create({ collection: def.name, locale: lang, ...draft, userId: c.get('user').id }));
2248
2461
  await record(c, 'document.created', doc.title, { collection: def.name, id: doc.id });
2249
- return c.json(toJson(doc), 201);
2462
+ return c.json(await toJson(doc), 201);
2250
2463
  });
2251
2464
  const loadDocument = async (id) => {
2252
2465
  const doc = await content.get(id);
@@ -2254,7 +2467,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2254
2467
  throw new ApiError(404, 'not_found', 'This document does not exist');
2255
2468
  return doc;
2256
2469
  };
2257
- app.get('/api/documents/:id', requireUser, async (c) => c.json(toJson(await loadDocument(c.req.param('id')))));
2470
+ app.get('/api/documents/:id', requireUser, async (c) => c.json(await toJson(await loadDocument(c.req.param('id')))));
2258
2471
  app.put('/api/documents/:id', requireUser, async (c) => {
2259
2472
  const existing = await loadDocument(c.req.param('id'));
2260
2473
  const input = await body(c, documentBody);
@@ -2265,10 +2478,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2265
2478
  if (f.kind === 'page')
2266
2479
  await guardDesign(c.get('user'), existing.data[name], draft.data[name]);
2267
2480
  const doc = await slugGuard(() => content.update(existing.id, { ...draft, userId: c.get('user').id }));
2268
- return c.json(toJson(doc));
2481
+ return c.json(await toJson(doc));
2269
2482
  });
2270
- app.post('/api/documents/:id/publish', requireUser, async (c) => {
2271
- const existing = await loadDocument(c.req.param('id'));
2483
+ /** Puts a document's draft on the site: the same steps from the button and from a schedule. */
2484
+ const publishDocument = async (existing, userId) => {
2272
2485
  // Validated now, not at save time: drafts may be incomplete and the schema may have changed since.
2273
2486
  const def = collection(existing.collection);
2274
2487
  const data = publishableData(def, existing.data);
@@ -2282,26 +2495,206 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2282
2495
  ? addMove(redirects, before, after)
2283
2496
  : redirects.filter((r) => normalizePath(r.from) !== normalizePath(after));
2284
2497
  if (next.length !== redirects.length || next.some((r, i) => r !== redirects[i]))
2285
- await settings.set('redirects', next, c.get('user').id);
2498
+ await settings.set('redirects', next, userId);
2286
2499
  }
2287
- const doc = (await content.publish(existing.id, c.get('user').id, data));
2288
- ctx.onPublish?.();
2289
- await record(c, 'document.published', doc.title, { collection: def.name, id: doc.id, url: after });
2290
- return c.json(toJson(doc));
2500
+ const doc = (await content.publish(existing.id, userId, data));
2501
+ sitePublished();
2502
+ return { doc, url: after };
2503
+ };
2504
+ const unpublishDocument = async (existing, userId) => {
2505
+ const doc = (await content.unpublish(existing.id, userId));
2506
+ if (existing.publishedAt)
2507
+ sitePublished();
2508
+ return doc;
2509
+ };
2510
+ app.post('/api/documents/:id/publish', requireUser, async (c) => {
2511
+ const existing = await loadDocument(c.req.param('id'));
2512
+ const { doc, url } = await publishDocument(existing, c.get('user').id);
2513
+ // Published by hand: a publication still waiting would only repeat it.
2514
+ await schedules.cancel(existing.id, 'publish');
2515
+ await record(c, 'document.published', doc.title, { collection: doc.collection, id: doc.id, url });
2516
+ return c.json(await toJson(doc));
2291
2517
  });
2292
2518
  app.post('/api/documents/:id/unpublish', requireUser, async (c) => {
2293
2519
  const existing = await loadDocument(c.req.param('id'));
2294
- const doc = (await content.unpublish(existing.id, c.get('user').id));
2295
- if (existing.publishedAt)
2296
- ctx.onPublish?.();
2520
+ const doc = await unpublishDocument(existing, c.get('user').id);
2521
+ await schedules.cancel(existing.id, 'unpublish');
2297
2522
  await record(c, 'document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
2298
- return c.json(toJson(doc));
2523
+ return c.json(await toJson(doc));
2524
+ });
2525
+ /**
2526
+ * When a document goes on the site, or comes off it, by itself. `at` null cancels. A publication
2527
+ * is checked now too, so missing fields show up when it is set, not when it fails at night.
2528
+ */
2529
+ app.put('/api/documents/:id/schedule', requireUser, async (c) => {
2530
+ const existing = await loadDocument(c.req.param('id'));
2531
+ const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']), at: z.iso.datetime({ offset: true }).nullable() }));
2532
+ const user = c.get('user');
2533
+ const label = input.action === 'publish' ? 'publish' : 'unpublish';
2534
+ if (input.at === null) {
2535
+ await schedules.cancel(existing.id, input.action);
2536
+ await record(c, `document.schedule_cancelled`, existing.title, {
2537
+ collection: existing.collection,
2538
+ id: existing.id,
2539
+ action: label,
2540
+ });
2541
+ return c.json(await toJson(existing));
2542
+ }
2543
+ const at = new Date(input.at);
2544
+ if (at.getTime() <= Date.now() + 30_000)
2545
+ throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
2546
+ const current = await schedules.forDocument(existing.id);
2547
+ if (input.action === 'publish')
2548
+ publishableData(collection(existing.collection), existing.data);
2549
+ else {
2550
+ if (!existing.publishedAt && !current.publish)
2551
+ throw new ApiError(409, 'not_published', 'La pagina non è sul sito: programma prima la pubblicazione');
2552
+ if (current.publish && current.publish.runAt >= at)
2553
+ throw new ApiError(400, 'before_publish', 'La pagina verrebbe tolta prima di essere pubblicata');
2554
+ }
2555
+ if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
2556
+ throw new ApiError(400, 'after_unpublish', 'La pubblicazione verrebbe dopo il momento in cui la pagina va tolta');
2557
+ await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
2558
+ await record(c, 'document.scheduled', existing.title, {
2559
+ collection: existing.collection,
2560
+ id: existing.id,
2561
+ action: label,
2562
+ at: at.toISOString(),
2563
+ });
2564
+ return c.json(await toJson(existing));
2565
+ });
2566
+ /**
2567
+ * Runs the actions whose time has come, as the person who set them. A server that was off at the
2568
+ * time catches up when it starts. A publication whose fields are no longer complete fails with a
2569
+ * reason the panel shows, instead of putting a broken page online.
2570
+ */
2571
+ let schedulesRunning = false;
2572
+ const runSchedules = async (now = new Date()) => {
2573
+ if (schedulesRunning)
2574
+ return 0;
2575
+ schedulesRunning = true;
2576
+ let ran = 0;
2577
+ try {
2578
+ for (const item of await schedules.due(now)) {
2579
+ const note = (action, target, detail) => activityLog
2580
+ .record({
2581
+ userId: item.createdBy,
2582
+ userName: item.createdByName,
2583
+ action,
2584
+ target,
2585
+ detail: { ...detail, scheduled: true },
2586
+ ip: null,
2587
+ })
2588
+ .catch((error) => console.error('Materialize: activity not recorded', error));
2589
+ const existing = await content.get(item.documentId);
2590
+ if (!existing) {
2591
+ await schedules.finish(item.id, 'failed', 'La pagina non esiste più');
2592
+ continue;
2593
+ }
2594
+ try {
2595
+ if (item.action === 'publish') {
2596
+ const def = ctx.config().collections.find((x) => x.name === existing.collection);
2597
+ const check = def ? validate(def, prepare(def, existing.data)) : null;
2598
+ if (!def || !check?.ok) {
2599
+ const fields = def && check && !check.ok
2600
+ ? [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))].join(', ')
2601
+ : '';
2602
+ throw new Error(def ? `Alcuni campi vanno completati${fields ? `: ${fields}` : ''}` : 'La raccolta non esiste più');
2603
+ }
2604
+ const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
2605
+ await schedules.finish(item.id, 'done');
2606
+ await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
2607
+ }
2608
+ else {
2609
+ const doc = await unpublishDocument(existing, item.createdBy ?? undefined);
2610
+ await schedules.finish(item.id, 'done');
2611
+ await note('document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
2612
+ }
2613
+ ran++;
2614
+ }
2615
+ catch (error) {
2616
+ const message = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
2617
+ await schedules.finish(item.id, 'failed', message);
2618
+ await note('document.schedule_failed', existing.title, {
2619
+ collection: existing.collection,
2620
+ id: existing.id,
2621
+ action: item.action,
2622
+ message,
2623
+ });
2624
+ }
2625
+ }
2626
+ }
2627
+ catch (error) {
2628
+ console.error('Materialize: scheduled publishing failed', error);
2629
+ }
2630
+ finally {
2631
+ schedulesRunning = false;
2632
+ }
2633
+ return ran;
2634
+ };
2635
+ setTimeout(() => void runSchedules(), 5_000).unref?.();
2636
+ setInterval(() => void runSchedules(), 30_000).unref?.();
2637
+ /** What is coming: every pending publication and removal, soonest first. */
2638
+ app.get('/api/schedules', requireUser, async (c) => {
2639
+ const items = await schedules.pending();
2640
+ const docs = new Map();
2641
+ for (const i of items)
2642
+ if (!docs.has(i.documentId))
2643
+ docs.set(i.documentId, await content.get(i.documentId));
2644
+ return c.json({
2645
+ items: items.flatMap((i) => {
2646
+ const doc = docs.get(i.documentId);
2647
+ return doc ? [{ ...i, title: doc.title, collection: doc.collection, locale: doc.locale }] : [];
2648
+ }),
2649
+ });
2650
+ });
2651
+ // ---------- shared preview links ----------
2652
+ const previewJson = (c, link) => {
2653
+ const path = `/_materialize/share/${link.token}`;
2654
+ return { ...link, path, url: new URL(path, publicOrigin(c)).toString() };
2655
+ };
2656
+ /** The links that still work for a document, newest first. */
2657
+ app.get('/api/documents/:id/previews', requireUser, async (c) => {
2658
+ const existing = await loadDocument(c.req.param('id'));
2659
+ return c.json({ items: (await previewLinks.forDocument(existing.id)).map((l) => previewJson(c, l)) });
2660
+ });
2661
+ /**
2662
+ * A secret address that shows the latest saved draft with the site's own design, to people
2663
+ * without an account: a client, a colleague. `days` null: until someone revokes it.
2664
+ */
2665
+ app.post('/api/documents/:id/previews', requireUser, async (c) => {
2666
+ const existing = await loadDocument(c.req.param('id'));
2667
+ const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
2668
+ if (!ctx.preview)
2669
+ throw new ApiError(409, 'no_site', 'Questo sito non ha pagine da mostrare in anteprima');
2670
+ if (!collection(existing.collection).url)
2671
+ throw new ApiError(400, 'no_page', 'Questo contenuto non ha una pagina sul sito');
2672
+ const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
2673
+ const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
2674
+ await record(c, 'document.preview_shared', existing.title, {
2675
+ collection: existing.collection,
2676
+ id: existing.id,
2677
+ expiresAt: expiresAt?.toISOString() ?? null,
2678
+ });
2679
+ return c.json(previewJson(c, link), 201);
2680
+ });
2681
+ app.delete('/api/previews/:id', requireUser, async (c) => {
2682
+ const link = await previewLinks.get(c.req.param('id'));
2683
+ if (!link)
2684
+ throw new ApiError(404, 'not_found', 'Link non trovato');
2685
+ const doc = await content.get(link.documentId);
2686
+ await previewLinks.revoke(link.id);
2687
+ await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
2688
+ return c.body(null, 204);
2299
2689
  });
2690
+ void previewLinks.prune().catch(() => { });
2300
2691
  app.delete('/api/documents/:id', requireUser, requireAdmin, async (c) => {
2301
2692
  const existing = await loadDocument(c.req.param('id'));
2302
2693
  await content.remove(existing.id);
2694
+ await schedules.removeForDocument(existing.id);
2695
+ await previewLinks.removeForDocument(existing.id);
2303
2696
  if (existing.publishedAt)
2304
- ctx.onPublish?.();
2697
+ sitePublished();
2305
2698
  await record(c, 'document.deleted', existing.title, { collection: existing.collection });
2306
2699
  return c.body(null, 204);
2307
2700
  });
@@ -2321,7 +2714,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2321
2714
  if (!doc)
2322
2715
  throw new ApiError(404, 'not_found', 'This version does not exist');
2323
2716
  await record(c, 'document.restored', doc.title, { collection: doc.collection, id: doc.id });
2324
- return c.json(toJson(doc));
2717
+ return c.json(await toJson(doc));
2325
2718
  });
2326
2719
  // ---------- languages and translations ----------
2327
2720
  /** How many documents each language has: removing a language hides them from the site. */
@@ -2346,7 +2739,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2346
2739
  const value = { default: input.default, available: [...new Set(input.available)] };
2347
2740
  await settings.set('locales', value, c.get('user').id);
2348
2741
  languages = value;
2349
- ctx.onPublish?.();
2742
+ sitePublished();
2350
2743
  await record(c, 'settings.languages', value.available.join(', '));
2351
2744
  return c.json({ locales: ctx.config().locales, documents: await countsByLocale() });
2352
2745
  });
@@ -2409,7 +2802,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2409
2802
  sourceId: original?.id ?? doc.id,
2410
2803
  }));
2411
2804
  await record(c, 'document.translated', created.title, { locale: lang, id: created.id, ai: input.translate });
2412
- return c.json(toJson(created), 201);
2805
+ return c.json(await toJson(created), 201);
2413
2806
  });
2414
2807
  /**
2415
2808
  * Brings a translation in line with its original: the original's structure and design, with the
@@ -2455,7 +2848,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2455
2848
  await guardDesign(c.get('user'), doc.data[name], data[name]);
2456
2849
  const draft = draftData(def, data);
2457
2850
  const saved = await slugGuard(() => content.update(doc.id, { ...draft, userId: c.get('user').id }));
2458
- return c.json({ document: toJson(saved), added: fresh.size });
2851
+ return c.json({ document: await toJson(saved), added: fresh.size });
2459
2852
  });
2460
2853
  // ---------- importing a WordPress site ----------
2461
2854
  /** Some hosts turn away requests that do not say who they are. */
@@ -2679,7 +3072,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2679
3072
  .then(async (result) => {
2680
3073
  await settings.set('imports:media', known, user.id);
2681
3074
  if (result.created.some((d) => d.published) || result.redirects)
2682
- ctx.onPublish?.();
3075
+ sitePublished();
2683
3076
  await activityLog.record({
2684
3077
  userId: user.id,
2685
3078
  userName: user.name,
@@ -2866,5 +3259,6 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2866
3259
  throw new ApiError(404, 'not_found', 'This page is not published');
2867
3260
  return c.json(publicDocument(doc));
2868
3261
  });
2869
- return app;
3262
+ // runSchedules: for the tests, which cannot wait for the clock.
3263
+ return Object.assign(app, { runSchedules });
2870
3264
  }
package/src/forms.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type FormDefinition } from '@materializecms/core/page';
2
+ import type { SubmissionFile } from '@materializecms/db';
3
+ /** Settings for every form, kept in the "forms" setting. */
4
+ export interface FormsSettings {
5
+ /** Messages older than this many months are deleted with their files; unset keeps them. */
6
+ keepMonths?: number;
7
+ /** Signs webhook calls, so the receiver can tell they come from this site. */
8
+ webhookSecret?: string;
9
+ }
10
+ export interface FieldIssue {
11
+ field: string;
12
+ message: string;
13
+ }
14
+ /** A sent file, before it is saved. */
15
+ export interface IncomingFile {
16
+ field: string;
17
+ name: string;
18
+ type: string;
19
+ bytes: Uint8Array;
20
+ }
21
+ /**
22
+ * What a form sent, checked against the form on the page: only its fields, the required ones
23
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
24
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
25
+ */
26
+ export declare function readSubmission(raw: Record<string, unknown>, def: FormDefinition | null): Promise<{
27
+ data: Record<string, string>;
28
+ files: IncomingFile[];
29
+ issues: FieldIssue[];
30
+ }>;
31
+ /**
32
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
33
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
34
+ */
35
+ export declare function spamReason(data: Record<string, string>, elapsedMs: number | null): string | null;
36
+ /** The same words from the same form: two identical messages in a day are a repeat. */
37
+ export declare const fingerprintOf: (form: string, data: Record<string, string>) => string;
38
+ /** Saves a message's files in its own folder, out of reach of the public site. */
39
+ export declare function saveFiles(dir: string, id: string, files: IncomingFile[]): Promise<SubmissionFile[]>;
40
+ export declare const removeFiles: (dir: string, id: string) => Promise<void>;
41
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
42
+ export declare const webhookSignature: (secret: string, body: string) => string;
43
+ /**
44
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
45
+ * longer each time. Resolves with whether it was taken.
46
+ */
47
+ export declare function sendWebhook(url: string, payload: unknown, secret: string, wait?: (ms: number) => Promise<void>): Promise<boolean>;
package/src/forms.js ADDED
@@ -0,0 +1,153 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { FILE_TYPES, FORM_FILE_MAX } from '@materializecms/core/page';
5
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+ const isFile = (v) => typeof v === 'object' && v !== null && 'arrayBuffer' in v && typeof v.name === 'string';
7
+ /**
8
+ * What a form sent, checked against the form on the page: only its fields, the required ones
9
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
10
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
11
+ */
12
+ export async function readSubmission(raw, def) {
13
+ const data = {};
14
+ const files = [];
15
+ const issues = [];
16
+ if (!def) {
17
+ for (const [k, v] of Object.entries(raw)) {
18
+ if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
19
+ continue;
20
+ data[k.slice(0, 80)] = v.slice(0, 5000);
21
+ }
22
+ return { data, files, issues };
23
+ }
24
+ for (const field of def.fields) {
25
+ const value = raw[field.name];
26
+ if (field.kind === 'file') {
27
+ const file = isFile(value) && value.size > 0 ? value : null;
28
+ if (!file) {
29
+ if (field.required)
30
+ issues.push({ field: field.name, message: 'Allega un file' });
31
+ continue;
32
+ }
33
+ const allowed = FILE_TYPES[field.accept ?? 'any'];
34
+ if (!allowed.includes(extname(file.name).toLowerCase())) {
35
+ issues.push({ field: field.name, message: `Questo tipo di file non è accettato (${allowed.join(', ')})` });
36
+ continue;
37
+ }
38
+ if (file.size > FORM_FILE_MAX) {
39
+ issues.push({ field: field.name, message: `Il file supera ${FORM_FILE_MAX / 1024 / 1024} MB` });
40
+ continue;
41
+ }
42
+ files.push({
43
+ field: field.name,
44
+ name: file.name,
45
+ type: file.type,
46
+ bytes: new Uint8Array(await file.arrayBuffer()),
47
+ });
48
+ data[field.name] = file.name;
49
+ continue;
50
+ }
51
+ const text = typeof value === 'string' ? value.trim().slice(0, 5000) : '';
52
+ if (field.kind === 'checkbox') {
53
+ if (text)
54
+ data[field.name] = 'sì';
55
+ else if (field.required)
56
+ issues.push({ field: field.name, message: 'Va spuntata per inviare' });
57
+ continue;
58
+ }
59
+ if (!text) {
60
+ if (field.required)
61
+ issues.push({ field: field.name, message: 'Campo obbligatorio' });
62
+ continue;
63
+ }
64
+ if (field.kind === 'email' && !EMAIL.test(text)) {
65
+ issues.push({ field: field.name, message: 'Scrivi un indirizzo email valido' });
66
+ continue;
67
+ }
68
+ if (field.kind === 'select' && field.options?.length && !field.options.includes(text)) {
69
+ issues.push({ field: field.name, message: 'Scegli una delle voci dell’elenco' });
70
+ continue;
71
+ }
72
+ data[field.name] = text;
73
+ }
74
+ return { data, files, issues };
75
+ }
76
+ /** A form filled in faster than this is a bot (people need a few seconds at least). */
77
+ const MIN_FILL_MS = 2500;
78
+ /**
79
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
80
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
81
+ */
82
+ export function spamReason(data, elapsedMs) {
83
+ if (elapsedMs !== null && elapsedMs >= 0 && elapsedMs < MIN_FILL_MS)
84
+ return 'Compilato in meno di 3 secondi';
85
+ const text = Object.values(data).join('\n');
86
+ if (/\[url=|<a\s+href|\[link=/i.test(text))
87
+ return 'Link nascosti nel testo';
88
+ const links = text.match(/https?:\/\/|www\./gi)?.length ?? 0;
89
+ if (links > 3)
90
+ return `Troppi link (${links})`;
91
+ return null;
92
+ }
93
+ /** The same words from the same form: two identical messages in a day are a repeat. */
94
+ export const fingerprintOf = (form, data) => createHash('sha256')
95
+ .update(form)
96
+ .update(JSON.stringify(Object.entries(data)
97
+ .sort(([a], [b]) => a.localeCompare(b))
98
+ .map(([k, v]) => [k, v.toLowerCase().replace(/\s+/g, ' ')])))
99
+ .digest('hex');
100
+ /** A file name safe on any disk, keeping its extension. */
101
+ const safeName = (name) => name
102
+ .normalize('NFKD')
103
+ .replace(/[^\w.-]+/g, '-')
104
+ .replace(/^-+|-+$/g, '')
105
+ .slice(-80) || 'file';
106
+ /** Saves a message's files in its own folder, out of reach of the public site. */
107
+ export async function saveFiles(dir, id, files) {
108
+ if (!files.length)
109
+ return [];
110
+ const folder = join(dir, id);
111
+ await mkdir(folder, { recursive: true });
112
+ const saved = [];
113
+ for (const [i, f] of files.entries()) {
114
+ const path = `${id}/${i + 1}-${safeName(f.name)}`;
115
+ await writeFile(join(dir, path), f.bytes);
116
+ saved.push({ field: f.field, name: f.name, size: f.bytes.length, type: f.type, path });
117
+ }
118
+ return saved;
119
+ }
120
+ export const removeFiles = (dir, id) => rm(join(dir, id), { recursive: true, force: true }).catch(() => { });
121
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
122
+ export const webhookSignature = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
123
+ /**
124
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
125
+ * longer each time. Resolves with whether it was taken.
126
+ */
127
+ export async function sendWebhook(url, payload, secret, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
128
+ const body = JSON.stringify(payload);
129
+ for (const delay of [0, 20_000, 120_000]) {
130
+ if (delay)
131
+ await wait(delay);
132
+ try {
133
+ const res = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ 'user-agent': 'Materialize-Webhook',
138
+ 'x-materialize-signature': webhookSignature(secret, body),
139
+ },
140
+ body,
141
+ redirect: 'error',
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+ if (res.ok)
145
+ return true;
146
+ // The receiver refused it on purpose: trying again would not help.
147
+ if (res.status >= 400 && res.status < 500 && res.status !== 429)
148
+ return false;
149
+ }
150
+ catch { }
151
+ }
152
+ return false;
153
+ }
package/src/tools.js CHANGED
@@ -257,7 +257,7 @@ export function assistantTools(call, page, brand) {
257
257
  },
258
258
  });
259
259
  return [
260
- ...contentTools(call, getSchema).filter((t) => t.name !== 'publish_entry' && t.name !== 'unpublish_entry'),
260
+ ...contentTools(call, getSchema).filter((t) => !['publish_entry', 'unpublish_entry', 'schedule_entry', 'share_preview'].includes(t.name)),
261
261
  ...pageTools({ source, withId: false, brand: async () => brand }),
262
262
  ];
263
263
  }
@@ -368,6 +368,38 @@ export function contentTools(call, getSchema) {
368
368
  return { id: doc.id, title: doc.title, published: false };
369
369
  },
370
370
  }),
371
+ tool({
372
+ name: 'schedule_entry',
373
+ title: 'Programmo',
374
+ description: 'Sets when an entry goes on the public site (publish) or comes off it (unpublish) by itself, as an ISO date with time zone, e.g. 2026-11-10T09:00:00+01:00. at null cancels. The draft as it is at that moment is what gets published.',
375
+ input: z.object({
376
+ id: z.string(),
377
+ action: z.enum(['publish', 'unpublish']),
378
+ at: z.string().nullable(),
379
+ }),
380
+ run: async (input) => {
381
+ const doc = await call('PUT', `/api/documents/${encodeURIComponent(input.id)}/schedule`, { action: input.action, at: input.at });
382
+ return {
383
+ id: doc.id,
384
+ title: doc.title,
385
+ publishAt: doc.schedule.publish?.runAt ?? null,
386
+ unpublishAt: doc.schedule.unpublish?.runAt ?? null,
387
+ };
388
+ },
389
+ }),
390
+ tool({
391
+ name: 'share_preview',
392
+ title: 'Creo un link di anteprima',
393
+ description: 'A secret link that shows an entry’s latest saved draft with the site’s design to someone without an account (a client, a colleague). It follows later edits. days: how long it works (default 7), null until revoked.',
394
+ input: z.object({
395
+ id: z.string(),
396
+ days: z.number().int().min(1).max(365).nullable().optional(),
397
+ }),
398
+ run: async (input) => {
399
+ const link = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/previews`, { days: input.days === undefined ? 7 : input.days });
400
+ return { url: link.url, expiresAt: link.expiresAt };
401
+ },
402
+ }),
371
403
  tool({
372
404
  name: 'list_translations',
373
405
  title: 'Guardo le lingue',