@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/CHANGELOG.md +27 -0
- package/package.json +4 -4
- package/src/ai.d.ts +35 -1
- package/src/ai.js +64 -6
- package/src/app.d.ts +3 -1
- package/src/app.js +543 -73
- package/src/forms.d.ts +47 -0
- package/src/forms.js +153 -0
- package/src/tools.js +33 -1
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 ----------
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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' : ''}
|
|
1876
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
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
|
|
1932
|
-
subject: `Nuovo messaggio dal sito${
|
|
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
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2195
|
-
|
|
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,
|
|
2498
|
+
await settings.set('redirects', next, userId);
|
|
2210
2499
|
}
|
|
2211
|
-
const doc = (await content.publish(existing.id,
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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 =
|
|
2219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3262
|
+
// runSchedules: for the tests, which cannot wait for the clock.
|
|
3263
|
+
return Object.assign(app, { runSchedules });
|
|
2794
3264
|
}
|