@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 +12 -0
- package/package.json +4 -4
- package/src/app.d.ts +3 -1
- package/src/app.js +456 -62
- package/src/forms.d.ts +47 -0
- package/src/forms.js +153 -0
- package/src/tools.js +33 -1
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.
|
|
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.
|
|
24
|
-
"@materializecms/core": "0.
|
|
25
|
-
"@materializecms/db": "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
|
-
/**
|
|
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ò
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
|
2008
|
-
subject: `Nuovo messaggio dal sito${
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2271
|
-
|
|
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,
|
|
2498
|
+
await settings.set('redirects', next, userId);
|
|
2286
2499
|
}
|
|
2287
|
-
const doc = (await content.publish(existing.id,
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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 =
|
|
2295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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',
|