@materializecms/server 0.3.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/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 ----------
@@ -1743,11 +1766,29 @@ export function createApp(base) {
1743
1766
  // A form on the site: the message is kept, and sent by email when email is set up.
1744
1767
  // ---------- AI: settings, text actions and the editor's assistant ----------
1745
1768
  const aiSettings = async () => ({ ...EMPTY_AI, ...(await settings.get('ai')) });
1769
+ const thisMonth = () => new Date().toISOString().slice(0, 7);
1770
+ const monthUsage = async () => {
1771
+ const saved = await settings.get('ai-usage');
1772
+ const month = thisMonth();
1773
+ return saved?.month === month ? saved : { month, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1774
+ };
1775
+ // One write at a time: the steps of a turn and parallel requests all count.
1776
+ let usageWrites = Promise.resolve();
1777
+ const countUsage = (u) => {
1778
+ usageWrites = usageWrites
1779
+ .then(async () => {
1780
+ const total = await monthUsage();
1781
+ for (const k of ['requests', 'input', 'output', 'cacheRead', 'cacheWrite'])
1782
+ total[k] += u[k];
1783
+ await settings.set('ai-usage', total);
1784
+ })
1785
+ .catch((error) => console.error(error));
1786
+ };
1746
1787
  const readyAi = async () => {
1747
1788
  const ai = await aiSettings();
1748
1789
  if (!isReady(ai))
1749
1790
  throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
1750
- return ai;
1791
+ return { ...ai, onUsage: countUsage };
1751
1792
  };
1752
1793
  const aiFailed = (error) => {
1753
1794
  if (error instanceof AiError)
@@ -1757,7 +1798,7 @@ export function createApp(base) {
1757
1798
  const allowAi = createLimiter(40, 60_000);
1758
1799
  app.get('/api/ai', requireUser, async (c) => {
1759
1800
  const ai = await aiSettings();
1760
- return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai) } : { ai: { ready: isReady(ai) } });
1801
+ return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai), usage: await monthUsage() } : { ai: { ready: isReady(ai) } });
1761
1802
  });
1762
1803
  app.put('/api/ai', requireUser, requireSession, requireAdmin, async (c) => {
1763
1804
  const input = await body(c, z.object({
@@ -1767,6 +1808,7 @@ export function createApp(base) {
1767
1808
  apiKey: z.string().max(500).nullable().optional(),
1768
1809
  baseUrl: z.union([z.url().max(500), z.literal('')]),
1769
1810
  model: z.string().trim().max(120),
1811
+ fastModel: z.string().trim().max(120).default(''),
1770
1812
  }),
1771
1813
  }));
1772
1814
  const before = await aiSettings();
@@ -1778,13 +1820,16 @@ export function createApp(base) {
1778
1820
  });
1779
1821
  app.post('/api/ai/test', requireUser, requireSession, requireAdmin, async (c) => {
1780
1822
  const ai = await readyAi();
1781
- const reply = await complete(ai, { system: 'Answer with one short word.', prompt: 'Say: ok' }).catch(aiFailed);
1823
+ const probe = { system: 'Answer with one short word.', prompt: 'Say: ok' };
1824
+ // Both models: a wrong name for the quick actions shows up here, not at the first ✨.
1825
+ const reply = await complete(ai, probe).catch(aiFailed);
1826
+ await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1782
1827
  return c.json({ ok: true, reply });
1783
1828
  });
1784
1829
  const TEXT_ACTIONS = {
1785
1830
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1786
- shorten: 'Make it shorter, keeping what matters.',
1787
- expand: 'Make it a little longer and richer, without inventing facts.',
1831
+ shorten: 'Make it shorter, keeping what matters most.',
1832
+ expand: 'Make it longer and richer, without inventing facts.',
1788
1833
  fix: 'Fix spelling, grammar and punctuation only; change nothing else.',
1789
1834
  formal: 'Make the tone more formal and professional.',
1790
1835
  friendly: 'Make the tone warmer and more conversational.',
@@ -1801,6 +1846,10 @@ export function createApp(base) {
1801
1846
  src: z.string().max(1000).optional(),
1802
1847
  /** The site's language, for texts written from scratch. */
1803
1848
  locale: z.string().max(20).optional(),
1849
+ /** rich: the text is rich text as lines of markup (richTextMarkup), and so is the reply. */
1850
+ format: z.enum(['plain', 'rich']).default('plain'),
1851
+ /** The most characters the field takes. */
1852
+ max: z.number().int().positive().max(100_000).optional(),
1804
1853
  }));
1805
1854
  if (!allowAi(c.get('user').id))
1806
1855
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
@@ -1813,9 +1862,14 @@ export function createApp(base) {
1813
1862
  if (!file)
1814
1863
  throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
1815
1864
  const data = (await readFile(file.path)).toString('base64');
1865
+ // The text already there may credit the photographer or the source: that part stays.
1866
+ const current = input.text.trim()
1867
+ ? `\n\nThe current alternative text is:\n<text>\n${input.text}\n</text>\nWrite in its language. If it credits a photographer, an author or a source (such as "photo by … on Unsplash"), keep that credit at the end, in the same form.`
1868
+ : '';
1816
1869
  const text = await complete(ai, {
1817
1870
  system,
1818
- prompt: `Write the alternative text (alt) for this image on a website, in the language with code "${lang}": one sentence, at most 125 characters, describing what matters for someone who cannot see it. No "image of".`,
1871
+ fast: true,
1872
+ prompt: `Write the alternative text (alt) for this image on a website, in the language with code "${lang}"${current ? ' unless said otherwise below' : ''}: one sentence, at most 125 characters (besides any credit), describing what matters for someone who cannot see it. No "image of".${current}`,
1819
1873
  image: { data, mime: 'image/jpeg' },
1820
1874
  }).catch(aiFailed);
1821
1875
  return c.json({ text });
@@ -1827,7 +1881,37 @@ export function createApp(base) {
1827
1881
  : input.action === 'custom'
1828
1882
  ? (input.instruction ?? 'Improve it.')
1829
1883
  : TEXT_ACTIONS[input.action];
1830
- const text = await complete(ai, { system, prompt: `${task}\n\n<text>\n${input.text}\n</text>` }).catch(aiFailed);
1884
+ const rich = input.format === 'rich'
1885
+ ? '\n\nThe text is markup, one block per line: <p>, <h2>, <h3>, <h4>, <blockquote>, <li>, with <b>, <i> and numbered links (<a1>…</a1>) inside, and <img1>, <img2>… for images. Reply in the same markup: you may change, merge, split or add blocks; keep each link around the matching words and every image line.'
1886
+ : '';
1887
+ // Shorter and longer by how much: as words, small models barely touch a short text or
1888
+ // multiply it. A length in characters (the words alone, without markup) they follow.
1889
+ const length = input.text.replace(/<[^>]+>/g, '').length;
1890
+ let max = input.max;
1891
+ let target = '';
1892
+ if (input.action === 'shorten') {
1893
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 0.65)}.`;
1894
+ max = Math.min(max ?? Number.POSITIVE_INFINITY, Math.round(length * 0.8));
1895
+ }
1896
+ else if (input.action === 'expand')
1897
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 1.5)}, never more than ${length * 2}.`;
1898
+ const limit = max ? `\n\nThe result must be at most ${max} characters, spaces included.` : '';
1899
+ let text = await complete(ai, {
1900
+ system,
1901
+ fast: true,
1902
+ prompt: `${task}${target}${limit}${rich}\n\n<text>\n${input.text}\n</text>`,
1903
+ }).catch(aiFailed);
1904
+ // Models count characters loosely: another pass when the result is too long, then one on the
1905
+ // main model when the quick one still does not get there (a small model may return the text as is).
1906
+ for (const fast of [true, false]) {
1907
+ if (!max || input.format !== 'plain' || text.length <= max)
1908
+ break;
1909
+ text = await complete(ai, {
1910
+ system,
1911
+ fast,
1912
+ prompt: `Shorten this text to at most ${max} characters, spaces included (it has ${text.length}), keeping its meaning and tone.\n\n<text>\n${text}\n</text>`,
1913
+ }).catch(aiFailed);
1914
+ }
1831
1915
  return c.json({ text });
1832
1916
  });
1833
1917
  /** Conversations with the assistant, per person and chat, kept for two hours in memory. */
@@ -1844,6 +1928,14 @@ export function createApp(base) {
1844
1928
  url: z.string().max(500).nullish(),
1845
1929
  }),
1846
1930
  selected: z.array(z.string().max(100)).max(50).optional(),
1931
+ /** Reference pictures (a screenshot, a mockup, a sketch), already made smaller by the panel. */
1932
+ images: z
1933
+ .array(z.object({
1934
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
1935
+ data: z.base64().max(4_000_000),
1936
+ }))
1937
+ .max(4)
1938
+ .default([]),
1847
1939
  }));
1848
1940
  const user = c.get('user');
1849
1941
  if (!allowAi(user.id))
@@ -1869,17 +1961,24 @@ export function createApp(base) {
1869
1961
  auth.cookie = cookie;
1870
1962
  const tools = assistantTools(apiCaller(app.fetch, auth), page, ctx.config().site.name);
1871
1963
  const limited = await contentOnly(user);
1872
- const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing the page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}).
1964
+ // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1965
+ // tied to them (a change is refused) and they are read from the prompt cache. What changes
1966
+ // from turn to turn, the page and what is selected, goes with each message instead.
1967
+ const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
1873
1968
  Use the tools to look at the page (get_page, get_node) and change it (edit_page). Your changes go into the editor, where the person sees them at once and can undo them; they are not published.
1874
1969
  Read the page before changing it. Make the changes asked, consistent with the design already there (theme colors and text styles, spacing, the look of nearby sections); prefer the section library for new sections. Keep ids of existing nodes.
1875
- ${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}When done, say in one or two short sentences what you changed. Reply in the person's language. If something cannot be done with the tools, say so.
1876
- ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).` : ''}`;
1970
+ ${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}Pictures the person attaches are references (a site they like, a mockup, a sketch): take their layout, hierarchy and mood, and build it with this site's sections, theme and texts; never copy their brand, logos or texts, and do not put the pictures on the page.
1971
+ When done, say in one or two short sentences what you changed. Reply in the person's language. If something cannot be done with the tools, say so.`;
1972
+ const context = `<context>Page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}). ${input.selected?.length
1973
+ ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).`
1974
+ : 'Nothing selected on the canvas.'}</context>`;
1877
1975
  return streamSSE(c, async (stream) => {
1878
1976
  const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
1879
1977
  try {
1880
1978
  const reply = await runTurn(ai, conv, {
1881
1979
  system,
1882
- text: input.message,
1980
+ text: `${context}\n\n${input.message}`,
1981
+ images: input.images,
1883
1982
  tools,
1884
1983
  onEvent: (e) => void send(e),
1885
1984
  });
@@ -1906,59 +2005,238 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
1906
2005
  }
1907
2006
  });
1908
2007
  });
1909
- 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) => {
1910
2058
  const form = c.req.param('form').slice(0, 80);
1911
2059
  const ip = clientIp(c);
1912
2060
  if (!allowForm(`${ip}:${form}`))
1913
2061
  throw new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
1914
- 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');
1915
2065
  const raw = (isJson ? await c.req.json().catch(() => ({})) : await c.req.parseBody());
1916
2066
  const page = typeof raw._page === 'string' ? raw._page.slice(0, 300) : (c.req.header('referer') ?? '').slice(0, 300);
1917
- // A field people never see: bots fill it in.
1918
- const trap = typeof raw._gotcha === 'string' && raw._gotcha.length > 0;
1919
- const data = {};
1920
- for (const [k, v] of Object.entries(raw)) {
1921
- if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
1922
- continue;
1923
- data[k.slice(0, 80)] = v.slice(0, 5000);
1924
- }
1925
- if (!trap && Object.values(data).some((v) => v.trim())) {
1926
- 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) {
1927
2090
  const mail = await settings.get('mail');
1928
- if (isConfigured(mail) && mail.to) {
1929
- 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
+ : '';
1930
2098
  sendMail(mail, {
1931
- to: mail.to,
1932
- subject: `Nuovo messaggio dal sito${typeof raw._subject === 'string' ? `: ${raw._subject.slice(0, 120)}` : ''}`,
1933
- text: submissionText(page, data),
1934
- replyTo,
2099
+ to,
2100
+ subject: `Nuovo messaggio dal sito${subject ? `: ${subject}` : ''}`,
2101
+ text: submissionText(page, data) + attached,
2102
+ replyTo: sender,
1935
2103
  })
1936
2104
  .then(() => submissions.markMailed(saved.id))
1937
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));
1938
2128
  }
1939
2129
  }
1940
2130
  // Without JavaScript the browser posted the form itself: back to the page, marked as sent.
1941
- if (!isJson) {
1942
- const back = (c.req.header('referer') ?? '/').split('#')[0];
1943
- return c.redirect(`${back}#inviato`, 303);
1944
- }
1945
- 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,
1946
2137
  });
1947
2138
  app.get('/api/submissions', requireUser, async (c) => {
1948
2139
  const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
1949
2140
  const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
1950
- 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
+ });
1951
2173
  });
1952
2174
  app.patch('/api/submissions/:id', requireUser, async (c) => {
1953
- const input = await body(c, z.object({ read: z.boolean() }));
1954
- 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);
1955
2181
  return c.json({ ok: true });
1956
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
+ });
1957
2201
  app.delete('/api/submissions/:id', requireUser, requireAdmin, async (c) => {
1958
2202
  await submissions.remove(c.req.param('id'));
2203
+ await removeFiles(uploadsDir, c.req.param('id'));
1959
2204
  await record(c, 'message.deleted');
1960
2205
  return c.body(null, 204);
1961
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?.();
1962
2240
  // Who may change what: editors limited to contents, if the admins want so.
1963
2241
  app.get('/api/editorial', requireUser, async (c) => c.json({ editorial: (await settings.get('editorial')) ?? {} }));
1964
2242
  app.put('/api/editorial', requireUser, requireSession, requireAdmin, async (c) => {
@@ -2011,7 +2289,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2011
2289
  }),
2012
2290
  }));
2013
2291
  await settings.set('tracking', input.tracking, c.get('user').id);
2014
- ctx.onPublish?.();
2292
+ sitePublished();
2015
2293
  await record(c, 'settings.tracking', '', {
2016
2294
  services: trackingBlocks(input.tracking).map((b) => b.name),
2017
2295
  });
@@ -2022,7 +2300,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2022
2300
  app.put('/api/seo', requireUser, requireAdmin, async (c) => {
2023
2301
  const input = await body(c, z.object({ seo: siteSeoSchema }));
2024
2302
  await settings.set('seo', input.seo, c.get('user').id);
2025
- ctx.onPublish?.();
2303
+ sitePublished();
2026
2304
  await record(c, 'settings.seo');
2027
2305
  return c.json({ seo: input.seo });
2028
2306
  });
@@ -2048,7 +2326,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2048
2326
  .map((r) => ({ ...r, from: normalizePath(r.from), to: /^https?:\/\//.test(r.to) ? r.to : normalizePath(r.to) }))
2049
2327
  .filter((r, i, all) => r.from !== r.to && all.findIndex((o) => o.from === r.from) === i);
2050
2328
  await settings.set('redirects', redirects, c.get('user').id);
2051
- ctx.onPublish?.();
2329
+ sitePublished();
2052
2330
  await record(c, 'settings.redirects', '', { count: redirects.length });
2053
2331
  return c.json({ redirects });
2054
2332
  });
@@ -2057,7 +2335,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2057
2335
  app.put('/api/templates', requireUser, async (c) => {
2058
2336
  const input = await body(c, z.object({ templates: z.record(z.string(), z.string()) }));
2059
2337
  await settings.set('templates', input.templates, c.get('user').id);
2060
- ctx.onPublish?.();
2338
+ sitePublished();
2061
2339
  return c.json({ templates: input.templates });
2062
2340
  });
2063
2341
  // Reusable components with their variants.
@@ -2069,7 +2347,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2069
2347
  if (JSON.stringify(before) !== JSON.stringify(input.components) && (await contentOnly(c.get('user'))))
2070
2348
  throw designLocked();
2071
2349
  await settings.set('components', input.components, c.get('user').id);
2072
- ctx.onPublish?.();
2350
+ sitePublished();
2073
2351
  return c.json({ components: input.components });
2074
2352
  });
2075
2353
  // Entries with their data, for lists bound to a collection on the canvas.
@@ -2157,7 +2435,18 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2157
2435
  return null;
2158
2436
  }
2159
2437
  };
2160
- 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
+ });
2161
2450
  });
2162
2451
  app.post('/api/collections/:name', requireUser, async (c) => {
2163
2452
  const def = collection(c.req.param('name'));
@@ -2170,7 +2459,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2170
2459
  guardEmbeds(c.get('user'), null, draft.data);
2171
2460
  const doc = await slugGuard(() => content.create({ collection: def.name, locale: lang, ...draft, userId: c.get('user').id }));
2172
2461
  await record(c, 'document.created', doc.title, { collection: def.name, id: doc.id });
2173
- return c.json(toJson(doc), 201);
2462
+ return c.json(await toJson(doc), 201);
2174
2463
  });
2175
2464
  const loadDocument = async (id) => {
2176
2465
  const doc = await content.get(id);
@@ -2178,7 +2467,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2178
2467
  throw new ApiError(404, 'not_found', 'This document does not exist');
2179
2468
  return doc;
2180
2469
  };
2181
- 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')))));
2182
2471
  app.put('/api/documents/:id', requireUser, async (c) => {
2183
2472
  const existing = await loadDocument(c.req.param('id'));
2184
2473
  const input = await body(c, documentBody);
@@ -2189,10 +2478,10 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2189
2478
  if (f.kind === 'page')
2190
2479
  await guardDesign(c.get('user'), existing.data[name], draft.data[name]);
2191
2480
  const doc = await slugGuard(() => content.update(existing.id, { ...draft, userId: c.get('user').id }));
2192
- return c.json(toJson(doc));
2481
+ return c.json(await toJson(doc));
2193
2482
  });
2194
- app.post('/api/documents/:id/publish', requireUser, async (c) => {
2195
- 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) => {
2196
2485
  // Validated now, not at save time: drafts may be incomplete and the schema may have changed since.
2197
2486
  const def = collection(existing.collection);
2198
2487
  const data = publishableData(def, existing.data);
@@ -2206,26 +2495,206 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2206
2495
  ? addMove(redirects, before, after)
2207
2496
  : redirects.filter((r) => normalizePath(r.from) !== normalizePath(after));
2208
2497
  if (next.length !== redirects.length || next.some((r, i) => r !== redirects[i]))
2209
- await settings.set('redirects', next, c.get('user').id);
2498
+ await settings.set('redirects', next, userId);
2210
2499
  }
2211
- const doc = (await content.publish(existing.id, c.get('user').id, data));
2212
- ctx.onPublish?.();
2213
- await record(c, 'document.published', doc.title, { collection: def.name, id: doc.id, url: after });
2214
- 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));
2215
2517
  });
2216
2518
  app.post('/api/documents/:id/unpublish', requireUser, async (c) => {
2217
2519
  const existing = await loadDocument(c.req.param('id'));
2218
- const doc = (await content.unpublish(existing.id, c.get('user').id));
2219
- if (existing.publishedAt)
2220
- ctx.onPublish?.();
2520
+ const doc = await unpublishDocument(existing, c.get('user').id);
2521
+ await schedules.cancel(existing.id, 'unpublish');
2221
2522
  await record(c, 'document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
2222
- return c.json(toJson(doc));
2523
+ return c.json(await toJson(doc));
2223
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);
2689
+ });
2690
+ void previewLinks.prune().catch(() => { });
2224
2691
  app.delete('/api/documents/:id', requireUser, requireAdmin, async (c) => {
2225
2692
  const existing = await loadDocument(c.req.param('id'));
2226
2693
  await content.remove(existing.id);
2694
+ await schedules.removeForDocument(existing.id);
2695
+ await previewLinks.removeForDocument(existing.id);
2227
2696
  if (existing.publishedAt)
2228
- ctx.onPublish?.();
2697
+ sitePublished();
2229
2698
  await record(c, 'document.deleted', existing.title, { collection: existing.collection });
2230
2699
  return c.body(null, 204);
2231
2700
  });
@@ -2245,7 +2714,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2245
2714
  if (!doc)
2246
2715
  throw new ApiError(404, 'not_found', 'This version does not exist');
2247
2716
  await record(c, 'document.restored', doc.title, { collection: doc.collection, id: doc.id });
2248
- return c.json(toJson(doc));
2717
+ return c.json(await toJson(doc));
2249
2718
  });
2250
2719
  // ---------- languages and translations ----------
2251
2720
  /** How many documents each language has: removing a language hides them from the site. */
@@ -2270,7 +2739,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2270
2739
  const value = { default: input.default, available: [...new Set(input.available)] };
2271
2740
  await settings.set('locales', value, c.get('user').id);
2272
2741
  languages = value;
2273
- ctx.onPublish?.();
2742
+ sitePublished();
2274
2743
  await record(c, 'settings.languages', value.available.join(', '));
2275
2744
  return c.json({ locales: ctx.config().locales, documents: await countsByLocale() });
2276
2745
  });
@@ -2333,7 +2802,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2333
2802
  sourceId: original?.id ?? doc.id,
2334
2803
  }));
2335
2804
  await record(c, 'document.translated', created.title, { locale: lang, id: created.id, ai: input.translate });
2336
- return c.json(toJson(created), 201);
2805
+ return c.json(await toJson(created), 201);
2337
2806
  });
2338
2807
  /**
2339
2808
  * Brings a translation in line with its original: the original's structure and design, with the
@@ -2379,7 +2848,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2379
2848
  await guardDesign(c.get('user'), doc.data[name], data[name]);
2380
2849
  const draft = draftData(def, data);
2381
2850
  const saved = await slugGuard(() => content.update(doc.id, { ...draft, userId: c.get('user').id }));
2382
- return c.json({ document: toJson(saved), added: fresh.size });
2851
+ return c.json({ document: await toJson(saved), added: fresh.size });
2383
2852
  });
2384
2853
  // ---------- importing a WordPress site ----------
2385
2854
  /** Some hosts turn away requests that do not say who they are. */
@@ -2603,7 +3072,7 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2603
3072
  .then(async (result) => {
2604
3073
  await settings.set('imports:media', known, user.id);
2605
3074
  if (result.created.some((d) => d.published) || result.redirects)
2606
- ctx.onPublish?.();
3075
+ sitePublished();
2607
3076
  await activityLog.record({
2608
3077
  userId: user.id,
2609
3078
  userName: user.name,
@@ -2790,5 +3259,6 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2790
3259
  throw new ApiError(404, 'not_found', 'This page is not published');
2791
3260
  return c.json(publicDocument(doc));
2792
3261
  });
2793
- return app;
3262
+ // runSchedules: for the tests, which cannot wait for the clock.
3263
+ return Object.assign(app, { runSchedules });
2794
3264
  }