@materializecms/server 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -8,6 +8,7 @@ import { Readable } from 'node:stream';
8
8
  import { getConnInfo } from '@hono/node-server/conninfo';
9
9
  import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
10
10
  import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
11
+ import { senderDomain } from '@materializecms/core/mail';
11
12
  import { normalizeTheme, sameDesign } from '@materializecms/core/page';
12
13
  import { addMove, normalizePath } from '@materializecms/core/seo';
13
14
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
@@ -27,9 +28,10 @@ import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts
27
28
  import { folderEntries, tarGz } from './archive.js';
28
29
  import { createBackups, DEFAULT_BACKUPS } from './backups.js';
29
30
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
30
- import { isConfigured, publicMail, sendMail, submissionText } from './mail.js';
31
+ import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
31
32
  import { createMcpServer } from './mcp.js';
32
33
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
34
+ import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './offsite.js';
33
35
  import { hashPassword, verifyPassword } from './password.js';
34
36
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
35
37
  import { statusPage } from './status-page.js';
@@ -203,8 +205,61 @@ export function createApp(base) {
203
205
  });
204
206
  };
205
207
  const backupFailed = (error) => console.error('Materialize: backup not made', error);
206
- setTimeout(() => void autoBackup().catch(backupFailed), 60_000).unref?.();
207
- setInterval(() => void autoBackup().catch(backupFailed), 60 * 60_000).unref?.();
208
+ // ---------- the copy outside the server (S3, WebDAV) ----------
209
+ const offsiteSettings = async () => ({
210
+ ...DEFAULT_OFFSITE,
211
+ ...(await settings.get('offsite')),
212
+ });
213
+ let offsiteRunning = false;
214
+ /** One run; the outcome is kept, and administrators hear by email the first time it fails. */
215
+ const offsiteRun = async (fresh) => {
216
+ if (offsiteRunning)
217
+ throw new ApiError(409, 'offsite_running', 'A copy is already being made');
218
+ offsiteRunning = true;
219
+ const before = await settings.get('offsite-status');
220
+ let status;
221
+ try {
222
+ status = await runOffsite(await offsiteSettings(), backups, ctx.config().media.dir, fresh);
223
+ }
224
+ catch (error) {
225
+ status = { at: new Date().toISOString(), ok: false, message: error.message };
226
+ if (before?.ok !== false)
227
+ for (const admin of (await auth.listUsers()).filter((u) => u.role === 'admin'))
228
+ await mailNotice(admin.email, 'Backup fuori dal server non riuscito', [
229
+ `La copia dei backup di ${ctx.config().site.name} fuori dal server non è riuscita: ${status.message}`,
230
+ 'Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.',
231
+ ]);
232
+ }
233
+ finally {
234
+ offsiteRunning = false;
235
+ }
236
+ await settings.set('offsite-status', status);
237
+ await activityLog.record({
238
+ userId: null,
239
+ userName: '',
240
+ action: status.ok ? 'backup.offsite' : 'backup.offsite_failed',
241
+ target: status.database ?? '',
242
+ detail: status.ok ? { media: status.media, fonts: status.fonts } : { message: status.message },
243
+ ip: null,
244
+ });
245
+ return status;
246
+ };
247
+ /** After the daily copy: once a day, unless the last run failed (then every hour until it works). */
248
+ const autoOffsite = async () => {
249
+ const s = await offsiteSettings();
250
+ if (s.kind === 'off' || !s.key || offsiteRunning)
251
+ return;
252
+ const last = await settings.get('offsite-status');
253
+ if (last?.ok && Date.now() - new Date(last.at).getTime() < 23 * 60 * 60_000)
254
+ return;
255
+ await offsiteRun(false);
256
+ };
257
+ const nightly = async () => {
258
+ await autoBackup().catch(backupFailed);
259
+ await autoOffsite().catch((error) => console.error('Materialize: offsite copy not made', error));
260
+ };
261
+ setTimeout(() => void nightly(), 60_000).unref?.();
262
+ setInterval(() => void nightly(), 60 * 60_000).unref?.();
208
263
  const updateSettings = async () => ({
209
264
  ...DEFAULT_UPDATES,
210
265
  ...(await settings.get('updates')),
@@ -980,7 +1035,35 @@ export function createApp(base) {
980
1035
  id: 'backups',
981
1036
  status: 'ok',
982
1037
  title: 'Backup del database ogni giorno',
983
- detail: `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round(age / 3_600_000), 'hour')}, immagini comprese; ne restano ${b.keep}. Per una copia fuori dal server, scarica l’archivio completo.`,
1038
+ detail: `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round(age / 3_600_000), 'hour')}, immagini comprese; ne restano ${b.keep}.`,
1039
+ });
1040
+ }
1041
+ {
1042
+ const o = await offsiteSettings();
1043
+ const status = await settings.get('offsite-status');
1044
+ checks.push(o.kind === 'off' || !o.key
1045
+ ? {
1046
+ id: 'offsite',
1047
+ status: 'warn',
1048
+ title: 'I backup restano sul server',
1049
+ detail: 'Se il server si rompe, si perdono anche le copie. Attiva la copia fuori dal server (S3 o WebDAV), oppure scarica ogni tanto l’archivio completo.',
1050
+ action: 'backups',
1051
+ }
1052
+ : status && !status.ok
1053
+ ? {
1054
+ id: 'offsite',
1055
+ status: 'bad',
1056
+ title: 'La copia fuori dal server non riesce',
1057
+ detail: status.message ?? 'Controlla il collegamento.',
1058
+ action: 'backups',
1059
+ }
1060
+ : {
1061
+ id: 'offsite',
1062
+ status: 'ok',
1063
+ title: 'Copia cifrata fuori dal server',
1064
+ detail: status
1065
+ ? `Ultima copia ${new Intl.RelativeTimeFormat('it', { numeric: 'auto' }).format(-Math.round((Date.now() - new Date(status.at).getTime()) / 3_600_000), 'hour')}, su ${o.kind === 's3' ? 'S3' : 'WebDAV'}.`
1066
+ : 'La prima copia parte entro un’ora.',
984
1067
  });
985
1068
  }
986
1069
  const tokens = await activityLog.tokens();
@@ -1047,6 +1130,100 @@ export function createApp(base) {
1047
1130
  await record(c, 'settings.backups', '', input);
1048
1131
  return c.json(await backupState());
1049
1132
  });
1133
+ /** What the panel may see: no passwords, no secret key, no recovery key. */
1134
+ const offsiteState = async () => {
1135
+ const s = await offsiteSettings();
1136
+ return {
1137
+ kind: s.kind,
1138
+ keep: s.keep,
1139
+ hasKey: !!s.key,
1140
+ s3: s.s3 ? { ...s.s3, secretAccessKey: '', hasSecret: !!s.s3.secretAccessKey } : undefined,
1141
+ webdav: s.webdav ? { ...s.webdav, password: '', hasSecret: !!s.webdav.password } : undefined,
1142
+ status: (await settings.get('offsite-status')) ?? null,
1143
+ running: offsiteRunning,
1144
+ };
1145
+ };
1146
+ const s3Input = z.object({
1147
+ endpoint: z.url({ protocol: /^https?$/ }).max(300),
1148
+ region: z.string().trim().max(60),
1149
+ bucket: z.string().trim().min(1).max(200),
1150
+ prefix: z.string().trim().max(200).optional(),
1151
+ accessKeyId: z.string().trim().min(1).max(200),
1152
+ // Empty: keep the saved one.
1153
+ secretAccessKey: z.string().max(500),
1154
+ });
1155
+ const webdavInput = z.object({
1156
+ url: z.url({ protocol: /^https?$/ }).max(500),
1157
+ user: z.string().trim().max(200),
1158
+ password: z.string().max(500),
1159
+ });
1160
+ const offsiteInput = z.object({
1161
+ kind: z.enum(['off', 's3', 'webdav']),
1162
+ keep: z.number().int().min(1).max(365),
1163
+ s3: s3Input.optional(),
1164
+ webdav: webdavInput.optional(),
1165
+ });
1166
+ /** The settings as sent, with the saved secrets where the panel left them empty. */
1167
+ const withSecrets = (input, saved) => {
1168
+ if (input.kind === 's3' && !input.s3)
1169
+ throw new ApiError(400, 'missing_s3', 'S3 settings are missing');
1170
+ if (input.kind === 'webdav' && !input.webdav)
1171
+ throw new ApiError(400, 'missing_webdav', 'WebDAV settings are missing');
1172
+ return {
1173
+ kind: input.kind,
1174
+ keep: input.keep,
1175
+ key: saved.key,
1176
+ s3: input.s3
1177
+ ? { ...input.s3, secretAccessKey: input.s3.secretAccessKey || saved.s3?.secretAccessKey || '' }
1178
+ : saved.s3,
1179
+ webdav: input.webdav
1180
+ ? { ...input.webdav, password: input.webdav.password || saved.webdav?.password || '' }
1181
+ : saved.webdav,
1182
+ };
1183
+ };
1184
+ app.get('/api/backups/offsite', requireUser, requireSession, requireAdmin, async (c) => c.json(await offsiteState()));
1185
+ /** Saves where the copy goes; the first time, makes the recovery key and returns it, once. */
1186
+ app.put('/api/backups/offsite', requireUser, requireSession, requireAdmin, async (c) => {
1187
+ const next = withSecrets(await body(c, offsiteInput), await offsiteSettings());
1188
+ let shown;
1189
+ if (next.kind !== 'off' && !next.key)
1190
+ next.key = shown = newRecoveryKey();
1191
+ await settings.set('offsite', next, c.get('user').id);
1192
+ await record(c, 'settings.offsite', next.kind);
1193
+ return c.json({ ...(await offsiteState()), recoveryKey: shown });
1194
+ });
1195
+ app.post('/api/backups/offsite/test', requireUser, requireSession, requireAdmin, async (c) => {
1196
+ try {
1197
+ await testOffsite(withSecrets(await body(c, offsiteInput), await offsiteSettings()));
1198
+ return c.json({ ok: true });
1199
+ }
1200
+ catch (error) {
1201
+ if (error instanceof ApiError)
1202
+ throw error;
1203
+ return c.json({ ok: false, message: error.message });
1204
+ }
1205
+ });
1206
+ /** "Esegui ora": a fresh copy of the database and whatever is missing, in the background. */
1207
+ app.post('/api/backups/offsite/run', requireUser, requireSession, requireAdmin, async (c) => {
1208
+ const s = await offsiteSettings();
1209
+ if (s.kind === 'off' || !s.key)
1210
+ throw new ApiError(409, 'offsite_off', 'The copy outside the server is off');
1211
+ if (offsiteRunning)
1212
+ throw new ApiError(409, 'offsite_running', 'A copy is already being made');
1213
+ void offsiteRun(true).catch((error) => console.error('Materialize: offsite copy not made', error));
1214
+ await record(c, 'backup.offsite_started');
1215
+ return c.json(await offsiteState(), 202);
1216
+ });
1217
+ /** The recovery key again, for an administrator who confirms their password. */
1218
+ app.post('/api/backups/offsite/key', requireUser, requireSession, requireAdmin, async (c) => {
1219
+ const input = await body(c, z.object({ password: z.string().min(1).max(500) }));
1220
+ await checkPassword(c.get('user').id, input.password);
1221
+ const s = await offsiteSettings();
1222
+ if (!s.key)
1223
+ throw new ApiError(404, 'no_key', 'There is no recovery key yet');
1224
+ await record(c, 'backup.key_shown');
1225
+ return c.json({ recoveryKey: s.key });
1226
+ });
1050
1227
  app.post('/api/backups', requireUser, requireSession, requireAdmin, async (c) => {
1051
1228
  if (!backups.available())
1052
1229
  throw new ApiError(409, 'backups_unavailable', 'The database is hosted: its provider keeps the backups');
@@ -1527,6 +1704,7 @@ export function createApp(base) {
1527
1704
  pass: z.string().max(500).nullable().optional(),
1528
1705
  from: z.string().max(200),
1529
1706
  to: z.string().max(200),
1707
+ provider: z.string().max(40).optional(),
1530
1708
  }),
1531
1709
  }));
1532
1710
  const before = await settings.get('mail');
@@ -1549,18 +1727,45 @@ export function createApp(base) {
1549
1727
  });
1550
1728
  }
1551
1729
  catch (error) {
1552
- throw new ApiError(422, 'mail_failed', error instanceof Error ? error.message : 'Sending failed');
1730
+ // What went wrong and what to do, in words: a blocked port, a wrong password, an unverified sender…
1731
+ return c.json({ ok: false, to, ...(await explainMailError(error, mail)) });
1553
1732
  }
1554
1733
  return c.json({ ok: true, to });
1555
1734
  });
1735
+ /** The sender's domain as mail servers see it: SPF, DKIM, DMARC. */
1736
+ app.post('/api/mail/domain', requireUser, requireSession, requireAdmin, async (c) => {
1737
+ const input = await body(c, z.object({ from: z.string().max(200), provider: z.string().max(40).optional() }));
1738
+ const domain = senderDomain(input.from);
1739
+ if (!domain)
1740
+ throw new ApiError(422, 'no_domain', 'Write the sender first, for example sito@example.com');
1741
+ return c.json({ domain, checks: await checkSenderDomain(domain, input.provider) });
1742
+ });
1556
1743
  // A form on the site: the message is kept, and sent by email when email is set up.
1557
1744
  // ---------- AI: settings, text actions and the editor's assistant ----------
1558
1745
  const aiSettings = async () => ({ ...EMPTY_AI, ...(await settings.get('ai')) });
1746
+ const thisMonth = () => new Date().toISOString().slice(0, 7);
1747
+ const monthUsage = async () => {
1748
+ const saved = await settings.get('ai-usage');
1749
+ const month = thisMonth();
1750
+ return saved?.month === month ? saved : { month, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1751
+ };
1752
+ // One write at a time: the steps of a turn and parallel requests all count.
1753
+ let usageWrites = Promise.resolve();
1754
+ const countUsage = (u) => {
1755
+ usageWrites = usageWrites
1756
+ .then(async () => {
1757
+ const total = await monthUsage();
1758
+ for (const k of ['requests', 'input', 'output', 'cacheRead', 'cacheWrite'])
1759
+ total[k] += u[k];
1760
+ await settings.set('ai-usage', total);
1761
+ })
1762
+ .catch((error) => console.error(error));
1763
+ };
1559
1764
  const readyAi = async () => {
1560
1765
  const ai = await aiSettings();
1561
1766
  if (!isReady(ai))
1562
1767
  throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
1563
- return ai;
1768
+ return { ...ai, onUsage: countUsage };
1564
1769
  };
1565
1770
  const aiFailed = (error) => {
1566
1771
  if (error instanceof AiError)
@@ -1570,7 +1775,7 @@ export function createApp(base) {
1570
1775
  const allowAi = createLimiter(40, 60_000);
1571
1776
  app.get('/api/ai', requireUser, async (c) => {
1572
1777
  const ai = await aiSettings();
1573
- return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai) } : { ai: { ready: isReady(ai) } });
1778
+ return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai), usage: await monthUsage() } : { ai: { ready: isReady(ai) } });
1574
1779
  });
1575
1780
  app.put('/api/ai', requireUser, requireSession, requireAdmin, async (c) => {
1576
1781
  const input = await body(c, z.object({
@@ -1580,6 +1785,7 @@ export function createApp(base) {
1580
1785
  apiKey: z.string().max(500).nullable().optional(),
1581
1786
  baseUrl: z.union([z.url().max(500), z.literal('')]),
1582
1787
  model: z.string().trim().max(120),
1788
+ fastModel: z.string().trim().max(120).default(''),
1583
1789
  }),
1584
1790
  }));
1585
1791
  const before = await aiSettings();
@@ -1591,13 +1797,16 @@ export function createApp(base) {
1591
1797
  });
1592
1798
  app.post('/api/ai/test', requireUser, requireSession, requireAdmin, async (c) => {
1593
1799
  const ai = await readyAi();
1594
- const reply = await complete(ai, { system: 'Answer with one short word.', prompt: 'Say: ok' }).catch(aiFailed);
1800
+ const probe = { system: 'Answer with one short word.', prompt: 'Say: ok' };
1801
+ // Both models: a wrong name for the quick actions shows up here, not at the first ✨.
1802
+ const reply = await complete(ai, probe).catch(aiFailed);
1803
+ await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1595
1804
  return c.json({ ok: true, reply });
1596
1805
  });
1597
1806
  const TEXT_ACTIONS = {
1598
1807
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1599
- shorten: 'Make it shorter, keeping what matters.',
1600
- expand: 'Make it a little longer and richer, without inventing facts.',
1808
+ shorten: 'Make it shorter, keeping what matters most.',
1809
+ expand: 'Make it longer and richer, without inventing facts.',
1601
1810
  fix: 'Fix spelling, grammar and punctuation only; change nothing else.',
1602
1811
  formal: 'Make the tone more formal and professional.',
1603
1812
  friendly: 'Make the tone warmer and more conversational.',
@@ -1614,6 +1823,10 @@ export function createApp(base) {
1614
1823
  src: z.string().max(1000).optional(),
1615
1824
  /** The site's language, for texts written from scratch. */
1616
1825
  locale: z.string().max(20).optional(),
1826
+ /** rich: the text is rich text as lines of markup (richTextMarkup), and so is the reply. */
1827
+ format: z.enum(['plain', 'rich']).default('plain'),
1828
+ /** The most characters the field takes. */
1829
+ max: z.number().int().positive().max(100_000).optional(),
1617
1830
  }));
1618
1831
  if (!allowAi(c.get('user').id))
1619
1832
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
@@ -1626,9 +1839,14 @@ export function createApp(base) {
1626
1839
  if (!file)
1627
1840
  throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
1628
1841
  const data = (await readFile(file.path)).toString('base64');
1842
+ // The text already there may credit the photographer or the source: that part stays.
1843
+ const current = input.text.trim()
1844
+ ? `\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.`
1845
+ : '';
1629
1846
  const text = await complete(ai, {
1630
1847
  system,
1631
- prompt: `Write the alternative text (alt) for this image on a website, in the language with code "${lang}": one sentence, at most 125 characters, describing what matters for someone who cannot see it. No "image of".`,
1848
+ fast: true,
1849
+ 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}`,
1632
1850
  image: { data, mime: 'image/jpeg' },
1633
1851
  }).catch(aiFailed);
1634
1852
  return c.json({ text });
@@ -1640,7 +1858,37 @@ export function createApp(base) {
1640
1858
  : input.action === 'custom'
1641
1859
  ? (input.instruction ?? 'Improve it.')
1642
1860
  : TEXT_ACTIONS[input.action];
1643
- const text = await complete(ai, { system, prompt: `${task}\n\n<text>\n${input.text}\n</text>` }).catch(aiFailed);
1861
+ const rich = input.format === 'rich'
1862
+ ? '\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.'
1863
+ : '';
1864
+ // Shorter and longer by how much: as words, small models barely touch a short text or
1865
+ // multiply it. A length in characters (the words alone, without markup) they follow.
1866
+ const length = input.text.replace(/<[^>]+>/g, '').length;
1867
+ let max = input.max;
1868
+ let target = '';
1869
+ if (input.action === 'shorten') {
1870
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 0.65)}.`;
1871
+ max = Math.min(max ?? Number.POSITIVE_INFINITY, Math.round(length * 0.8));
1872
+ }
1873
+ else if (input.action === 'expand')
1874
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 1.5)}, never more than ${length * 2}.`;
1875
+ const limit = max ? `\n\nThe result must be at most ${max} characters, spaces included.` : '';
1876
+ let text = await complete(ai, {
1877
+ system,
1878
+ fast: true,
1879
+ prompt: `${task}${target}${limit}${rich}\n\n<text>\n${input.text}\n</text>`,
1880
+ }).catch(aiFailed);
1881
+ // Models count characters loosely: another pass when the result is too long, then one on the
1882
+ // main model when the quick one still does not get there (a small model may return the text as is).
1883
+ for (const fast of [true, false]) {
1884
+ if (!max || input.format !== 'plain' || text.length <= max)
1885
+ break;
1886
+ text = await complete(ai, {
1887
+ system,
1888
+ fast,
1889
+ 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>`,
1890
+ }).catch(aiFailed);
1891
+ }
1644
1892
  return c.json({ text });
1645
1893
  });
1646
1894
  /** Conversations with the assistant, per person and chat, kept for two hours in memory. */
@@ -1657,6 +1905,14 @@ export function createApp(base) {
1657
1905
  url: z.string().max(500).nullish(),
1658
1906
  }),
1659
1907
  selected: z.array(z.string().max(100)).max(50).optional(),
1908
+ /** Reference pictures (a screenshot, a mockup, a sketch), already made smaller by the panel. */
1909
+ images: z
1910
+ .array(z.object({
1911
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
1912
+ data: z.base64().max(4_000_000),
1913
+ }))
1914
+ .max(4)
1915
+ .default([]),
1660
1916
  }));
1661
1917
  const user = c.get('user');
1662
1918
  if (!allowAi(user.id))
@@ -1682,17 +1938,24 @@ export function createApp(base) {
1682
1938
  auth.cookie = cookie;
1683
1939
  const tools = assistantTools(apiCaller(app.fetch, auth), page, ctx.config().site.name);
1684
1940
  const limited = await contentOnly(user);
1685
- const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing the page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}).
1941
+ // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1942
+ // tied to them (a change is refused) and they are read from the prompt cache. What changes
1943
+ // from turn to turn, the page and what is selected, goes with each message instead.
1944
+ 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.
1686
1945
  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.
1687
1946
  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.
1688
- ${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}When done, say in one or two short sentences what you changed. Reply in the person's language. If something cannot be done with the tools, say so.
1689
- ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).` : ''}`;
1947
+ ${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.
1948
+ 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.`;
1949
+ const context = `<context>Page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}). ${input.selected?.length
1950
+ ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).`
1951
+ : 'Nothing selected on the canvas.'}</context>`;
1690
1952
  return streamSSE(c, async (stream) => {
1691
1953
  const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
1692
1954
  try {
1693
1955
  const reply = await runTurn(ai, conv, {
1694
1956
  system,
1695
- text: input.message,
1957
+ text: `${context}\n\n${input.message}`,
1958
+ images: input.images,
1696
1959
  tools,
1697
1960
  onEvent: (e) => void send(e),
1698
1961
  });
@@ -2044,7 +2307,13 @@ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')}
2044
2307
  });
2045
2308
  app.get('/api/documents/:id/revisions', requireUser, async (c) => {
2046
2309
  const existing = await loadDocument(c.req.param('id'));
2047
- return c.json({ items: await content.revisions(existing.id) });
2310
+ // Who made each version, by name; null for a person removed since (and for versions with no author).
2311
+ const names = new Map((await auth.listUsers()).map((u) => [u.id, u.name]));
2312
+ const items = (await content.revisions(existing.id)).map((r) => ({
2313
+ ...r,
2314
+ authorName: r.authorId ? (names.get(r.authorId) ?? null) : null,
2315
+ }));
2316
+ return c.json({ items });
2048
2317
  });
2049
2318
  app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
2050
2319
  const existing = await loadDocument(c.req.param('id'));
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { restoreMedia } from './backups.ts';
9
9
  export { createFonts, type FontInfo, type Fonts } from './fonts.ts';
10
10
  export { createMcpServer, registerTools } from './mcp.ts';
11
11
  export { createMediaFiles, type MediaFiles } from './media.ts';
12
+ export { decryptTree, type OffsiteSettings, parseRecoveryKey } from './offsite.ts';
12
13
  export { hashPassword, verifyPassword } from './password.ts';
13
14
  export { apiCaller, siteTools } from './tools.ts';
14
15
  /** Serves the public site: every request outside /api, /admin and /media goes here. */
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ export { restoreMedia } from './backups.js';
10
10
  export { createFonts } from './fonts.js';
11
11
  export { createMcpServer, registerTools } from './mcp.js';
12
12
  export { createMediaFiles } from './media.js';
13
+ export { decryptTree, parseRecoveryKey } from './offsite.js';
13
14
  export { hashPassword, verifyPassword } from './password.js';
14
15
  export { apiCaller, siteTools } from './tools.js';
15
16
  const OWN = /^\/(api|admin|media|fonts|mcp)(\/|\?|$)/;
package/src/mail.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface MailSettings {
10
10
  from: string;
11
11
  /** Where messages from the site's forms arrive. */
12
12
  to: string;
13
+ /** The provider chosen in the panel (core/mail.ts), or empty for "another one". */
14
+ provider?: string;
13
15
  }
14
16
  export declare const EMPTY_MAIL: MailSettings;
15
17
  export declare function isConfigured(m: MailSettings | null): m is MailSettings;
@@ -21,6 +23,7 @@ export declare function publicMail(m: MailSettings | null): {
21
23
  user: string;
22
24
  from: string;
23
25
  to: string;
26
+ provider?: string;
24
27
  hasPassword: boolean;
25
28
  };
26
29
  export declare function sendMail(m: MailSettings, message: {
@@ -31,3 +34,27 @@ export declare function sendMail(m: MailSettings, message: {
31
34
  }): Promise<void>;
32
35
  /** A message from a site form as plain text: one "Label: value" per field. */
33
36
  export declare function submissionText(page: string, data: Record<string, string>): string;
37
+ /** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
38
+ export declare function portOpen(host: string, port: number, timeout?: number): Promise<boolean>;
39
+ export interface MailProblem {
40
+ message: string;
41
+ /** What to do about it. */
42
+ hint?: string;
43
+ }
44
+ /**
45
+ * A sending error from nodemailer, explained: wrong server name, a port the hosting blocks (then
46
+ * the ports that do answer), security that does not match the port, credentials refused, a sender
47
+ * the provider has not verified, a limit reached.
48
+ */
49
+ export declare function explainMailError(error: unknown, m: MailSettings): Promise<MailProblem>;
50
+ export interface DomainCheck {
51
+ id: 'spf' | 'dkim' | 'dmarc';
52
+ state: 'ok' | 'warn' | 'bad' | 'info';
53
+ title: string;
54
+ detail: string;
55
+ }
56
+ /**
57
+ * What mail servers check before trusting a message from this domain. Without SPF and DKIM the
58
+ * messages tend to end in spam; Gmail and Yahoo also ask for DMARC from whoever sends a lot.
59
+ */
60
+ export declare function checkSenderDomain(domain: string, providerId?: string): Promise<DomainCheck[]>;