@materializecms/server 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,2548 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { getConnInfo } from '@hono/node-server/conninfo';
9
+ import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
10
+ import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
11
+ import { normalizeTheme, sameDesign } from '@materializecms/core/page';
12
+ import { addMove, normalizePath } from '@materializecms/core/seo';
13
+ import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
14
+ import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
15
+ import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
16
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
17
+ import { Hono } from 'hono';
18
+ import { bodyLimit } from 'hono/body-limit';
19
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
20
+ import { csrf } from 'hono/csrf';
21
+ import { HTTPException } from 'hono/http-exception';
22
+ import { secureHeaders } from 'hono/secure-headers';
23
+ import { streamSSE } from 'hono/streaming';
24
+ import { z } from 'zod';
25
+ import { serveAdmin } from './admin.js';
26
+ import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts, } from './ai.js';
27
+ import { folderEntries, tarGz } from './archive.js';
28
+ import { createBackups, DEFAULT_BACKUPS } from './backups.js';
29
+ import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
30
+ import { isConfigured, publicMail, sendMail, submissionText } from './mail.js';
31
+ import { createMcpServer } from './mcp.js';
32
+ import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
33
+ import { hashPassword, verifyPassword } from './password.js';
34
+ import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
35
+ import { statusPage } from './status-page.js';
36
+ import { apiCaller, assistantTools } from './tools.js';
37
+ import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
38
+ import { readRest, readWxr, WordPressError } from './wordpress.js';
39
+ import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
40
+ /** The installed version, from this package's package.json (same place in the sources and in the release). */
41
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
42
+ const SESSION_COOKIE = 'mz_session';
43
+ const SESSION_TTL = 30 * 24 * 60 * 60 * 1000;
44
+ /** A session left unused this long ends, whatever its expiry. */
45
+ const SESSION_IDLE = 14 * 24 * 60 * 60 * 1000;
46
+ /** Failed sign-ins before a pause: per email (one account), and per address (one attacker, many accounts). */
47
+ const EMAIL_THRESHOLD = 5;
48
+ const IP_THRESHOLD = 20;
49
+ /** A password accepted, the code still to come: how long the second step may take. */
50
+ const CHALLENGE_TTL = 5 * 60 * 1000;
51
+ /** Checked against when an email has no account, so both answers take as long. */
52
+ const dummyHash = hashPassword(randomBytes(16).toString('hex'));
53
+ const INVITE_TTL = 7 * 24 * 60 * 60 * 1000;
54
+ const RESET_TTL = 24 * 60 * 60 * 1000;
55
+ /** Where people open their one-time link: the admin's welcome screen. */
56
+ const linkPath = (token) => `/admin/_invite/${token}`;
57
+ /** The custom code blocks in a document: it runs on the public site, so only admins may change it. */
58
+ function embedsOf(value, out = []) {
59
+ if (Array.isArray(value))
60
+ for (const v of value)
61
+ embedsOf(v, out);
62
+ else if (value && typeof value === 'object') {
63
+ const node = value;
64
+ if (node.type === 'embed' && typeof node.id === 'string')
65
+ out.push(`${node.id}:${String(node.html ?? '')}`);
66
+ for (const v of Object.values(node))
67
+ if (v && typeof v === 'object')
68
+ embedsOf(v, out);
69
+ }
70
+ return out;
71
+ }
72
+ class ApiError extends HTTPException {
73
+ constructor(status, code, message, issues) {
74
+ super(status, { res: Response.json({ error: { code, message, issues } }, { status }) });
75
+ }
76
+ }
77
+ const credentials = z.object({ email: z.email(), password: z.string().min(1) });
78
+ const setupBody = z.object({
79
+ email: z.email(),
80
+ name: z.string().trim().min(1),
81
+ password: z.string().min(10),
82
+ /** From the link `materialize start` prints, when it set one. */
83
+ token: z.string().optional(),
84
+ site: z
85
+ .object({
86
+ name: z.string().trim().min(1).max(120).optional(),
87
+ locale: z.string().refine(isLocaleCode, 'Codice lingua non valido').optional(),
88
+ })
89
+ .optional(),
90
+ });
91
+ const roleSchema = z.enum(['admin', 'editor']);
92
+ const documentBody = z.object({ data: z.record(z.string(), z.unknown()), locale: z.string().optional() });
93
+ const mediaRef = z
94
+ .object({
95
+ src: z.string().min(1).max(1000),
96
+ alt: z.string().max(500),
97
+ width: z.number().optional(),
98
+ height: z.number().optional(),
99
+ })
100
+ .optional();
101
+ const short = z.string().trim().max(200).optional();
102
+ const siteSeoSchema = z.object({
103
+ siteName: short,
104
+ titleTemplate: short,
105
+ description: z.string().max(300).optional(),
106
+ image: mediaRef,
107
+ favicon: mediaRef,
108
+ xHandle: short,
109
+ business: z
110
+ .object({
111
+ type: z
112
+ .enum(['Organization', 'LocalBusiness', 'ProfessionalService', 'Store', 'Restaurant', 'Person'])
113
+ .optional(),
114
+ name: short,
115
+ logo: mediaRef,
116
+ phone: short,
117
+ email: short,
118
+ street: short,
119
+ city: short,
120
+ postalCode: short,
121
+ region: short,
122
+ country: short,
123
+ hours: short,
124
+ sameAs: z.array(z.string().trim().max(500)).max(20).optional(),
125
+ })
126
+ .optional(),
127
+ blockAi: z.boolean().optional(),
128
+ disallow: z.array(z.string().trim().max(300)).max(100).optional(),
129
+ google: short,
130
+ bing: short,
131
+ });
132
+ const redirectSchema = z.object({
133
+ from: z.string().trim().min(1).max(500),
134
+ to: z.string().trim().min(1).max(1000),
135
+ auto: z.boolean().optional(),
136
+ });
137
+ async function body(c, schema) {
138
+ const json = await c.req.json().catch(() => {
139
+ throw new ApiError(400, 'invalid_json', 'The request body must be JSON');
140
+ });
141
+ const result = schema.safeParse(json);
142
+ if (!result.success) {
143
+ throw new ApiError(422, 'invalid_request', 'The request is not valid', result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })));
144
+ }
145
+ return result.data;
146
+ }
147
+ /** Naive in-memory limiter: enough to slow down password guessing on a single instance. */
148
+ function createLimiter(max, windowMs) {
149
+ const hits = new Map();
150
+ return (key) => {
151
+ const now = Date.now();
152
+ const entry = hits.get(key);
153
+ if (!entry || entry.until < now) {
154
+ hits.set(key, { n: 1, until: now + windowMs });
155
+ return true;
156
+ }
157
+ entry.n += 1;
158
+ return entry.n <= max;
159
+ };
160
+ }
161
+ export function createApp(base) {
162
+ // The languages chosen in Impostazioni → Lingue win over the config's; read once, then kept current.
163
+ let languages = null;
164
+ const ctx = { ...base, config: () => withLocales(base.config(), languages) };
165
+ const content = createContentStore(ctx.database.db);
166
+ const auth = createAuthStore(ctx.database.db);
167
+ const mediaStore = createMediaStore(ctx.database.db);
168
+ const settings = createSettingsStore(ctx.database.db);
169
+ const languagesLoaded = settings.get('locales').then((v) => {
170
+ languages = v;
171
+ });
172
+ const submissions = createSubmissionStore(ctx.database.db);
173
+ const comments = createCommentStore(ctx.database.db);
174
+ const activityLog = createActivityStore(ctx.database.db);
175
+ const backups = createBackups(ctx.database, () => ctx.config().media.dir);
176
+ const backupSettings = async () => ({
177
+ ...DEFAULT_BACKUPS,
178
+ ...(await settings.get('backups')),
179
+ });
180
+ /** Once a day, a copy of the database, made by the server itself; old copies go. */
181
+ const autoBackup = async () => {
182
+ const s = await backupSettings();
183
+ if (!s.enabled || !backups.available())
184
+ return;
185
+ const last = (await backups.list())[0];
186
+ if (last && Date.now() - last.createdAt.getTime() < 23 * 60 * 60_000)
187
+ return;
188
+ const made = await backups.create('auto');
189
+ await backups.prune(s.keep);
190
+ await backups.mirrorMedia();
191
+ await activityLog.record({
192
+ userId: null,
193
+ userName: '',
194
+ action: 'backup.created',
195
+ target: made.name,
196
+ detail: { auto: true },
197
+ ip: null,
198
+ });
199
+ };
200
+ const backupFailed = (error) => console.error('Materialize: backup not made', error);
201
+ setTimeout(() => void autoBackup().catch(backupFailed), 60_000).unref?.();
202
+ setInterval(() => void autoBackup().catch(backupFailed), 60 * 60_000).unref?.();
203
+ const updateSettings = async () => ({
204
+ ...DEFAULT_UPDATES,
205
+ ...(await settings.get('updates')),
206
+ });
207
+ /** A copy of the database (and of the media) right before an update. */
208
+ const backupBeforeUpdate = async () => {
209
+ if (!backups.available())
210
+ return;
211
+ await backups.create('prima-dell-aggiornamento');
212
+ await backups.prune((await backupSettings()).keep);
213
+ await backups.mirrorMedia();
214
+ };
215
+ /** Once a day: is there a new version? Fixes install by themselves when the admin allowed it. */
216
+ const autoUpdate = async () => {
217
+ if (!ctx.updates)
218
+ return;
219
+ const s = await ctx.updates.check();
220
+ if (!(await updateSettings()).auto || s.unavailable || !s.latest || !isPatch(s.latest, s.current))
221
+ return;
222
+ // Not again a version that already failed to start here, or one the admin went back from.
223
+ if (s.last && !s.last.ok && s.last.kind === 'install' && s.last.to === s.latest)
224
+ return;
225
+ if (s.last?.kind === 'rollback' && s.last.from === s.latest)
226
+ return;
227
+ await ctx.updates.install(s.latest, backupBeforeUpdate);
228
+ await activityLog.record({
229
+ userId: null,
230
+ userName: '',
231
+ action: 'update.install',
232
+ target: s.latest,
233
+ detail: { auto: true, from: s.current },
234
+ ip: null,
235
+ });
236
+ };
237
+ const updateFailed = (error) => console.error('Materialize: update check failed', error);
238
+ // Soon after starting, so the panel knows at once (the registry answers in a moment).
239
+ setTimeout(() => void autoUpdate().catch(updateFailed), 15_000).unref?.();
240
+ setInterval(() => void autoUpdate().catch(updateFailed), 24 * 60 * 60_000).unref?.();
241
+ /**
242
+ * Who has a document open, in memory: each editor checks in every few seconds with the element it
243
+ * has selected. Gone after 30 s of silence.
244
+ */
245
+ const presence = new Map();
246
+ // A form can be sent a few times a minute from one address: enough for people, not for spam.
247
+ const allowForm = createLimiter(5, 60_000);
248
+ const files = createMediaFiles(ctx.config().media.dir);
249
+ // Fonts are cached next to the media library (.materialize/fonts by default).
250
+ const fonts = createFonts(join(dirname(resolve(ctx.config().media.dir)), 'fonts'));
251
+ const app = new Hono();
252
+ const guardEmbeds = (user, before, after) => {
253
+ if (user.role === 'admin')
254
+ return;
255
+ if (JSON.stringify(embedsOf(before).sort()) !== JSON.stringify(embedsOf(after).sort()))
256
+ throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può aggiungere o modificare il codice personalizzato');
257
+ };
258
+ /** Editors who may only change contents (Persone → Redattori): words, links and images, not the layout. */
259
+ const contentOnly = async (user) => user.role !== 'admin' && !!(await settings.get('editorial'))?.editorsContentOnly;
260
+ const designLocked = () => new ApiError(403, 'content_only', 'Puoi modificare testi, collegamenti e immagini, non il layout della pagina');
261
+ const guardDesign = async (user, before, after) => {
262
+ const pages = (v) => (Array.isArray(v) ? v : []);
263
+ if (!sameDesign(pages(before), pages(after)) && (await contentOnly(user)))
264
+ throw designLocked();
265
+ };
266
+ const collection = (name) => {
267
+ const found = ctx.config().collections.find((x) => x.name === name);
268
+ if (!found)
269
+ throw new ApiError(404, 'unknown_collection', `There is no collection named "${name}"`);
270
+ return found;
271
+ };
272
+ const locale = (value) => {
273
+ const { locales } = ctx.config();
274
+ const chosen = value ?? locales.default;
275
+ if (!locales.available.includes(chosen))
276
+ throw new ApiError(400, 'unknown_locale', `Locale "${chosen}" is not enabled`);
277
+ return chosen;
278
+ };
279
+ /**
280
+ * Who is connecting: the socket's address, or the proxy's X-Forwarded-For when the config says
281
+ * the site sits behind one (otherwise anyone could send that header and pretend).
282
+ */
283
+ const clientIp = (c) => {
284
+ if (ctx.config().server.trustProxy) {
285
+ const forwarded = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip');
286
+ if (forwarded)
287
+ return forwarded;
288
+ }
289
+ try {
290
+ return getConnInfo(c).remote.address ?? 'local';
291
+ }
292
+ catch {
293
+ return 'local';
294
+ }
295
+ };
296
+ /**
297
+ * Notes what happened in the activity log (Impostazioni → Sicurezza). `who` defaults to the
298
+ * signed-in person; a failure to write never stops the request.
299
+ */
300
+ const record = async (c, action, target = '', detail = null, who) => {
301
+ const user = who === undefined ? c.get('user') : who;
302
+ try {
303
+ await activityLog.record({
304
+ userId: user?.id ?? null,
305
+ userName: user?.name ?? '',
306
+ action,
307
+ target,
308
+ detail,
309
+ ip: clientIp(c),
310
+ });
311
+ }
312
+ catch (error) {
313
+ console.error('Materialize: activity not recorded', error);
314
+ }
315
+ };
316
+ const startSession = async (c, userId) => {
317
+ const { token, expiresAt } = await auth.createSession(userId, SESSION_TTL, {
318
+ userAgent: c.req.header('user-agent'),
319
+ ip: clientIp(c),
320
+ });
321
+ setCookie(c, SESSION_COOKIE, token, {
322
+ httpOnly: true,
323
+ sameSite: 'Lax',
324
+ secure: new URL(c.req.url).protocol === 'https:',
325
+ path: '/',
326
+ expires: expiresAt,
327
+ });
328
+ };
329
+ /** The bearer token of a request, if it sends one (personal access tokens). */
330
+ const bearerOf = (c) => /^Bearer\s+(\S+)$/i.exec(c.req.header('authorization') ?? '')?.[1];
331
+ const requireUser = async (c, next) => {
332
+ const bearer = bearerOf(c);
333
+ if (bearer) {
334
+ const user = await auth.userForToken(bearer);
335
+ if (!user)
336
+ throw new ApiError(401, 'invalid_token', 'This access token is not valid or was revoked');
337
+ if (!user.twoFactor && (await twoFactorRequired()))
338
+ throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
339
+ c.set('user', user);
340
+ c.set('viaToken', true);
341
+ return next();
342
+ }
343
+ const token = getCookie(c, SESSION_COOKIE);
344
+ const user = token ? await auth.userForSession(token, SESSION_IDLE) : null;
345
+ if (!user)
346
+ throw new ApiError(401, 'unauthenticated', 'Sign in to continue');
347
+ c.set('user', user);
348
+ // When the site requires two-step verification, someone without it can only set it up.
349
+ if (!user.twoFactor && !c.req.path.startsWith('/api/auth/') && (await twoFactorRequired()))
350
+ throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
351
+ await next();
352
+ };
353
+ const twoFactorRequired = async () => !!(await settings.get('security'))?.requireTwoFactor;
354
+ /** Only from the panel: a token cannot make or revoke tokens, nor change who someone is. */
355
+ const requireSession = async (c, next) => {
356
+ if (c.get('viaToken'))
357
+ throw new ApiError(403, 'session_required', 'Do this from the admin panel');
358
+ await next();
359
+ };
360
+ const requireAdmin = async (c, next) => {
361
+ if (c.get('user').role !== 'admin')
362
+ throw new ApiError(403, 'forbidden', 'Only administrators can do this');
363
+ await next();
364
+ };
365
+ /** The public address of a document, with the locale prefix outside the default locale. */
366
+ const publicUrl = (def, locale, data) => {
367
+ if (!def.url)
368
+ return null;
369
+ const path = def.url(data);
370
+ if (locale === ctx.config().locales.default)
371
+ return path;
372
+ return `/${locale}${path === '/' ? '' : path}`;
373
+ };
374
+ /** A document as the admin sees it, with what still needs fixing before it can be published. */
375
+ const toJson = (doc) => {
376
+ const def = ctx.config().collections.find((x) => x.name === doc.collection);
377
+ const check = def ? validate(def, prepare(def, doc.data)) : null;
378
+ return {
379
+ ...doc,
380
+ url: def?.url ? def.url(doc.data) : null,
381
+ issues: check && !check.ok ? check.issues : [],
382
+ };
383
+ };
384
+ /**
385
+ * Drafts are saved even when incomplete, so autosave never loses work: problems come back as `issues` (see toJson).
386
+ * Only valid content can be published.
387
+ */
388
+ const draftData = (def, raw) => {
389
+ const prepared = prepare(def, raw);
390
+ const result = validate(def, prepared);
391
+ const data = result.ok ? result.data : prepared;
392
+ const slugValue = def.slugField ? data[def.slugField] : undefined;
393
+ const slug = typeof slugValue === 'string' && SLUG_PATTERN.test(slugValue) ? slugValue : null;
394
+ return { data, slug, title: titleOf(def, data) };
395
+ };
396
+ const publishableData = (def, raw) => {
397
+ const result = validate(def, prepare(def, raw));
398
+ if (!result.ok)
399
+ throw new ApiError(422, 'invalid_content', 'Some fields need attention', result.issues);
400
+ return result.data;
401
+ };
402
+ const slugGuard = async (run) => {
403
+ try {
404
+ return await run();
405
+ }
406
+ catch (error) {
407
+ if (error instanceof SlugTakenError)
408
+ throw new ApiError(409, 'slug_taken', error.message);
409
+ throw error;
410
+ }
411
+ };
412
+ app.use('*', secureHeaders());
413
+ // Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
414
+ const checkOrigin = csrf();
415
+ app.use('/api/*', (c, next) => (bearerOf(c) ? next() : checkOrigin(c, next)));
416
+ // Answers change with every edit: browsers must not reuse them.
417
+ app.use('/api/*', async (c, next) => {
418
+ await next();
419
+ if (!c.res.headers.has('cache-control'))
420
+ c.res.headers.set('cache-control', 'no-store');
421
+ });
422
+ app.onError((error, c) => {
423
+ if (error instanceof HTTPException)
424
+ return error.getResponse();
425
+ console.error(error);
426
+ return c.json({ error: { code: 'internal', message: 'Something went wrong on the server' } }, 500);
427
+ });
428
+ app.notFound((c) => c.json({ error: { code: 'not_found', message: 'Not found' } }, 404));
429
+ app.get('/', async (c) => c.html(statusPage(ctx.config(), VERSION, (await auth.countUsers()) === 0)));
430
+ app.get('/admin', (c) => c.redirect('/admin/'));
431
+ app.get('/admin/*', (c) => serveAdmin(c, ctx.adminDir));
432
+ app.get('/api/health', (c) => c.json({ ok: true, version: VERSION }));
433
+ // MCP (Streamable HTTP, stateless): AI clients connect with a personal access token and act as
434
+ // its owner through the same API, checks included.
435
+ app.all('/mcp', requireUser, async (c) => {
436
+ const auth = {};
437
+ const authorization = c.req.header('authorization');
438
+ const cookie = c.req.header('cookie');
439
+ if (authorization)
440
+ auth.authorization = authorization;
441
+ else if (cookie)
442
+ auth.cookie = cookie;
443
+ const server = createMcpServer(apiCaller(app.fetch, auth), VERSION);
444
+ const transport = new WebStandardStreamableHTTPServerTransport({
445
+ sessionIdGenerator: undefined,
446
+ enableJsonResponse: true,
447
+ });
448
+ await server.connect(transport);
449
+ return transport.handleRequest(c.req.raw);
450
+ });
451
+ // ---------- setup & auth ----------
452
+ /** What the installation checks before it starts: each with what to do when it is not right. */
453
+ const setupChecks = async (c) => {
454
+ const checks = [];
455
+ const node = Number(process.versions.node.split('.')[0]);
456
+ checks.push({
457
+ id: 'node',
458
+ label: `Node.js ${process.versions.node}`,
459
+ state: node >= 24 ? 'ok' : 'error',
460
+ detail: node >= 24 ? 'Versione adatta.' : 'Serve Node 24 o più recente.',
461
+ });
462
+ const mediaDir = resolve(ctx.config().media.dir);
463
+ let writable = true;
464
+ try {
465
+ await mkdir(mediaDir, { recursive: true });
466
+ await access(mediaDir, constants.W_OK);
467
+ }
468
+ catch {
469
+ writable = false;
470
+ }
471
+ checks.push({
472
+ id: 'files',
473
+ label: 'Cartella dei file',
474
+ state: writable ? 'ok' : 'error',
475
+ detail: writable
476
+ ? 'Database e immagini si possono salvare.'
477
+ : `Il programma non può scrivere in ${mediaDir}: dagli i permessi su quella cartella.`,
478
+ });
479
+ checks.push({
480
+ id: 'site',
481
+ label: 'Sito',
482
+ state: ctx.preview ? 'ok' : 'warn',
483
+ detail: ctx.preview
484
+ ? 'Il sito si genera da solo a ogni pubblicazione.'
485
+ : 'Solo il CMS: in questa cartella non c’è un sito Astro.',
486
+ });
487
+ const url = new URL(c.req.url);
488
+ const local = /^(localhost|127\.|\[::1\])/.test(url.hostname);
489
+ const https = url.protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https';
490
+ checks.push({
491
+ id: 'https',
492
+ label: 'Connessione sicura',
493
+ state: https || local ? 'ok' : 'warn',
494
+ detail: https
495
+ ? 'HTTPS attivo.'
496
+ : local
497
+ ? 'In locale non serve HTTPS.'
498
+ : 'Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).',
499
+ });
500
+ const updates = await ctx.updates?.status();
501
+ checks.push({
502
+ id: 'updates',
503
+ label: 'Aggiornamenti',
504
+ state: updates && !updates.unavailable ? 'ok' : 'warn',
505
+ detail: updates && !updates.unavailable
506
+ ? 'Si installano dal pannello, con un clic.'
507
+ : (updates?.unavailable ?? 'Si installano da terminale.'),
508
+ });
509
+ return checks;
510
+ };
511
+ app.get('/api/setup', async (c) => {
512
+ const needsSetup = (await auth.countUsers()) === 0;
513
+ if (!needsSetup)
514
+ return c.json({ needsSetup });
515
+ await languagesLoaded;
516
+ const config = ctx.config();
517
+ const named = config.collections.find((def) => def.singleton && def.fields.siteName);
518
+ const current = named
519
+ ? (await content.list(named.name, { locale: config.locales.default, limit: 1 })).items[0]
520
+ : null;
521
+ const doc = current ? await content.get(current.id) : null;
522
+ return c.json({
523
+ needsSetup,
524
+ needsToken: !!ctx.setupToken,
525
+ checks: await setupChecks(c),
526
+ site: {
527
+ name: doc?.data.siteName || config.site.name,
528
+ locale: config.locales.default,
529
+ },
530
+ });
531
+ });
532
+ app.post('/api/setup', async (c) => {
533
+ const input = await body(c, setupBody);
534
+ if ((await auth.countUsers()) > 0)
535
+ throw new ApiError(409, 'already_set_up', 'An administrator already exists');
536
+ if (ctx.setupToken && input.token !== ctx.setupToken)
537
+ throw new ApiError(403, 'setup_token', 'Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice');
538
+ // The site's language and name, before anything else is written in them.
539
+ if (input.site?.locale && input.site.locale !== ctx.config().locales.default) {
540
+ const value = { default: input.site.locale, available: [input.site.locale] };
541
+ await settings.set('locales', value);
542
+ languages = value;
543
+ }
544
+ if (input.site?.name)
545
+ await nameSite(input.site.name);
546
+ const user = await auth.createUser({
547
+ email: input.email,
548
+ name: input.name,
549
+ role: 'admin',
550
+ passwordHash: await hashPassword(input.password),
551
+ });
552
+ await startSession(c, user.id);
553
+ await record(c, 'user.setup', user.email, null, user);
554
+ return c.json({ user }, 201);
555
+ });
556
+ /** The site's name lives in its settings document (a single document with a `siteName` field), published. */
557
+ const nameSite = async (name) => {
558
+ const config = ctx.config();
559
+ const def = config.collections.find((d) => d.singleton && d.fields.siteName);
560
+ if (!def)
561
+ return;
562
+ const lang = config.locales.default;
563
+ const found = (await content.list(def.name, { locale: lang, limit: 1 })).items[0];
564
+ const existing = found ? await content.get(found.id) : null;
565
+ const draft = draftData(def, { ...existing?.data, siteName: name });
566
+ const doc = existing
567
+ ? await content.update(existing.id, draft)
568
+ : await content.create({ collection: def.name, locale: lang, ...draft });
569
+ if (doc)
570
+ await content.publish(doc.id);
571
+ ctx.onPublish?.();
572
+ };
573
+ /** The site's name as set in the panel (the settings singleton, published), else the config's. */
574
+ const siteName = async () => {
575
+ const config = ctx.config();
576
+ const def = config.collections.find((d) => d.singleton && d.fields.siteName);
577
+ const found = def && (await content.list(def.name, { locale: config.locales.default, limit: 1 })).items[0];
578
+ const name = found ? (await content.get(found.id))?.publishedData?.siteName : null;
579
+ return typeof name === 'string' && name.trim() ? name : config.site.name;
580
+ };
581
+ /**
582
+ * Sign-in pauses: after repeated failures an email, or an address, waits before trying again
583
+ * (1, 2, 4… minutes). Saved in the database, so restarting the server does not reset them.
584
+ */
585
+ const checkLocks = async (...keys) => {
586
+ for (const key of keys) {
587
+ const until = await auth.lockedUntil(key);
588
+ if (until) {
589
+ const minutes = Math.max(1, Math.ceil((until.getTime() - Date.now()) / 60_000));
590
+ throw new ApiError(429, 'too_many_attempts', `Troppi tentativi: riprova tra ${minutes === 1 ? 'un minuto' : `${minutes} minuti`}`);
591
+ }
592
+ }
593
+ };
594
+ /** A failed sign-in: counted for the email and the address; the owner hears when their account pauses. */
595
+ const signInFailed = async (email, ip, hasAccount) => {
596
+ const byEmail = await auth.recordFailure(`email:${email}`, EMAIL_THRESHOLD);
597
+ await auth.recordFailure(`ip:${ip}`, IP_THRESHOLD);
598
+ if (hasAccount && byEmail.failures === EMAIL_THRESHOLD)
599
+ await mailNotice(email, 'Tentativi di accesso non riusciti', [
600
+ `Qualcuno ha provato ${EMAIL_THRESHOLD} volte di fila a entrare nel pannello del sito con il tuo indirizzo, senza la password giusta. Per sicurezza gli accessi con il tuo indirizzo sono in pausa per qualche minuto.`,
601
+ 'Se eri tu, aspetta e riprova. Se non eri tu, la tua password è ancora al sicuro: per più protezione attiva la verifica in due passaggi dal tuo profilo.',
602
+ ]);
603
+ };
604
+ /** Passwords accepted, codes still due: by ticket, in memory for a few minutes. */
605
+ const challenges = new Map();
606
+ const challenge = (userId) => {
607
+ const now = Date.now();
608
+ for (const [k, v] of challenges)
609
+ if (v.until < now)
610
+ challenges.delete(k);
611
+ const ticket = randomBytes(24).toString('base64url');
612
+ challenges.set(ticket, { userId, until: now + CHALLENGE_TTL });
613
+ return ticket;
614
+ };
615
+ /** Signs in, or asks for the second step when the account has two-step verification. */
616
+ const signIn = async (c, user, status = 200) => {
617
+ if (user.twoFactor)
618
+ return c.json({ twoFactor: true, ticket: challenge(user.id) }, 200);
619
+ await startSession(c, user.id);
620
+ await record(c, 'login.ok', user.email, null, user);
621
+ return c.json({ user }, status);
622
+ };
623
+ app.post('/api/auth/login', async (c) => {
624
+ const input = await body(c, credentials);
625
+ const email = input.email.trim().toLowerCase();
626
+ const ip = clientIp(c);
627
+ try {
628
+ await checkLocks(`email:${email}`, `ip:${ip}`);
629
+ }
630
+ catch (error) {
631
+ await record(c, 'login.blocked', email, null, null);
632
+ throw error;
633
+ }
634
+ const found = await auth.findForLogin(email);
635
+ // A password is checked even for an unknown email: the answer takes as long either way.
636
+ const ok = await verifyPassword(input.password, found?.passwordHash ?? (await dummyHash));
637
+ if (!found || !ok) {
638
+ await signInFailed(email, ip, !!found);
639
+ await record(c, 'login.failed', email, { known: !!found }, found ? { id: found.id, name: found.name } : null);
640
+ throw new ApiError(401, 'invalid_credentials', 'Email or password is not correct');
641
+ }
642
+ await auth.clearFailures(`email:${email}`);
643
+ const { passwordHash: _, ...user } = found;
644
+ return signIn(c, user);
645
+ });
646
+ /** The second step: a code from the app, or a recovery code. */
647
+ app.post('/api/auth/login/verify', async (c) => {
648
+ const input = await body(c, z.object({ ticket: z.string().min(1).max(100), code: z.string().trim().min(6).max(20) }));
649
+ const pending = challenges.get(input.ticket);
650
+ if (!pending || pending.until < Date.now()) {
651
+ challenges.delete(input.ticket);
652
+ throw new ApiError(401, 'challenge_expired', 'Sign in again');
653
+ }
654
+ const key = `2fa:${pending.userId}`;
655
+ await checkLocks(key, `ip:${clientIp(c)}`);
656
+ const state = await auth.twoFactorOf(pending.userId);
657
+ let ok = false;
658
+ if (state?.secret) {
659
+ const step = verifyCode(state.secret, input.code, state.lastStep);
660
+ if (step !== null) {
661
+ await auth.useStep(pending.userId, step);
662
+ ok = true;
663
+ }
664
+ else if (input.code.replace(/\s/g, '').length > 6)
665
+ ok = await auth.useRecoveryCode(pending.userId, input.code);
666
+ }
667
+ if (!ok) {
668
+ const who = await auth.getUser(pending.userId);
669
+ await record(c, 'login.code_failed', who?.email ?? '', null, who);
670
+ await auth.recordFailure(key, EMAIL_THRESHOLD);
671
+ await auth.recordFailure(`ip:${clientIp(c)}`, IP_THRESHOLD);
672
+ throw new ApiError(401, 'invalid_code', 'The code is not correct');
673
+ }
674
+ challenges.delete(input.ticket);
675
+ await auth.clearFailures(key);
676
+ const user = await auth.getUser(pending.userId);
677
+ if (!user)
678
+ throw new ApiError(401, 'unauthenticated', 'Sign in to continue');
679
+ await startSession(c, user.id);
680
+ await record(c, 'login.ok', user.email, { twoFactor: true }, user);
681
+ return c.json({ user });
682
+ });
683
+ app.post('/api/auth/logout', async (c) => {
684
+ const token = getCookie(c, SESSION_COOKIE);
685
+ if (token) {
686
+ const user = await auth.userForSession(token);
687
+ await auth.deleteSession(token);
688
+ if (user)
689
+ await record(c, 'logout', user.email, null, user);
690
+ }
691
+ deleteCookie(c, SESSION_COOKIE, { path: '/' });
692
+ return c.json({ ok: true });
693
+ });
694
+ app.get('/api/auth/me', requireUser, async (c) => c.json({ user: c.get('user'), needsTwoFactor: !c.get('user').twoFactor && (await twoFactorRequired()) }));
695
+ // ---------- account security: two-step verification and devices ----------
696
+ const checkPassword = async (userId, password, path = 'password') => {
697
+ const hash = await auth.getPasswordHash(userId);
698
+ if (!hash || !(await verifyPassword(password, hash)))
699
+ throw new ApiError(422, 'wrong_password', 'The password is not correct', [
700
+ { path, message: 'La password non è corretta' },
701
+ ]);
702
+ };
703
+ app.get('/api/auth/security', requireUser, requireSession, async (c) => {
704
+ const user = c.get('user');
705
+ const state = await auth.twoFactorOf(user.id);
706
+ const current = auth.sessionId(getCookie(c, SESSION_COOKIE) ?? '');
707
+ return c.json({
708
+ twoFactor: { enabled: !!state?.secret, recoveryLeft: state?.recoveryLeft ?? 0 },
709
+ required: await twoFactorRequired(),
710
+ sessions: (await auth.listSessions(user.id)).map((s) => ({ ...s, current: s.id === current })),
711
+ });
712
+ });
713
+ /** Step one: a new secret to scan; it is used only once a code confirms it. */
714
+ app.post('/api/auth/2fa/setup', requireUser, requireSession, async (c) => {
715
+ const user = c.get('user');
716
+ if (user.twoFactor)
717
+ throw new ApiError(409, 'two_factor_on', 'Two-step verification is already on');
718
+ const secret = newSecret();
719
+ await auth.setPendingSecret(user.id, secret);
720
+ return c.json({ secret, uri: otpauthUri(secret, user.email, ctx.config().site.name) });
721
+ });
722
+ /** Step two: the first code turns it on; the recovery codes are shown this once. */
723
+ app.post('/api/auth/2fa/enable', requireUser, requireSession, async (c) => {
724
+ const input = await body(c, z.object({ code: z.string().trim().min(6).max(10) }));
725
+ const user = c.get('user');
726
+ const state = await auth.twoFactorOf(user.id);
727
+ if (!state?.pending)
728
+ throw new ApiError(409, 'no_setup', 'Start the setup again');
729
+ const step = verifyCode(state.pending, input.code);
730
+ if (step === null)
731
+ throw new ApiError(422, 'invalid_code', 'The code is not correct', [
732
+ { path: 'code', message: 'Il codice non è corretto' },
733
+ ]);
734
+ const codes = recoveryCodes();
735
+ await auth.enableTwoFactor(user.id, step, codes);
736
+ await record(c, 'twofactor.enabled', user.email);
737
+ return c.json({ recoveryCodes: codes });
738
+ });
739
+ app.post('/api/auth/2fa/disable', requireUser, requireSession, async (c) => {
740
+ const input = await body(c, z.object({ password: z.string().min(1) }));
741
+ if (await twoFactorRequired())
742
+ throw new ApiError(409, 'two_factor_required', 'This site requires two-step verification');
743
+ await checkPassword(c.get('user').id, input.password);
744
+ await auth.disableTwoFactor(c.get('user').id);
745
+ await record(c, 'twofactor.disabled', c.get('user').email);
746
+ return c.json({ ok: true });
747
+ });
748
+ app.post('/api/auth/2fa/recovery', requireUser, requireSession, async (c) => {
749
+ const input = await body(c, z.object({ password: z.string().min(1) }));
750
+ await checkPassword(c.get('user').id, input.password);
751
+ if (!c.get('user').twoFactor)
752
+ throw new ApiError(409, 'two_factor_off', 'Two-step verification is off');
753
+ const codes = recoveryCodes();
754
+ await auth.setRecoveryCodes(c.get('user').id, codes);
755
+ await record(c, 'twofactor.recovery', c.get('user').email);
756
+ return c.json({ recoveryCodes: codes });
757
+ });
758
+ app.delete('/api/auth/sessions/:id', requireUser, requireSession, async (c) => {
759
+ if (!(await auth.endSession(c.get('user').id, c.req.param('id'))))
760
+ throw new ApiError(404, 'not_found', 'This device is not signed in');
761
+ await record(c, 'session.ended', c.get('user').email);
762
+ return c.json({ ok: true });
763
+ });
764
+ app.post('/api/auth/sessions/others', requireUser, requireSession, async (c) => {
765
+ const ended = await auth.endOtherSessions(c.get('user').id, getCookie(c, SESSION_COOKIE));
766
+ await record(c, 'session.ended_others', c.get('user').email, { ended });
767
+ return c.json({ ended });
768
+ });
769
+ // The site's rules (Persone): two-step verification for everyone.
770
+ app.get('/api/security', requireUser, requireAdmin, async (c) => c.json({ requireTwoFactor: await twoFactorRequired() }));
771
+ /** Groups of the activity log, by the start of their action codes. */
772
+ const ACTIVITY_GROUPS = {
773
+ access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.'],
774
+ people: ['user.'],
775
+ content: ['document.', 'media.', 'message.'],
776
+ settings: ['settings.', 'backup.', 'update.'],
777
+ };
778
+ app.get('/api/activity', requireUser, requireAdmin, async (c) => {
779
+ const group = c.req.query('group');
780
+ const before = Number(c.req.query('before'));
781
+ const items = await activityLog.list({
782
+ prefixes: group ? ACTIVITY_GROUPS[group] : undefined,
783
+ before: before || undefined,
784
+ limit: 50,
785
+ });
786
+ return c.json({ items });
787
+ });
788
+ /**
789
+ * What an administrator should look at, in order: who signs in without a second step, whether
790
+ * warnings can reach people by email, the connection, recent failed sign-ins, old tokens.
791
+ */
792
+ app.get('/api/security/overview', requireUser, requireAdmin, async (c) => {
793
+ const checks = [];
794
+ const people = await auth.listUsers();
795
+ const required = await twoFactorRequired();
796
+ const admins = people.filter((p) => p.role === 'admin');
797
+ const adminsWithout = admins.filter((p) => !p.twoFactor);
798
+ const without = people.filter((p) => !p.twoFactor);
799
+ checks.push(adminsWithout.length
800
+ ? {
801
+ id: 'admins-2fa',
802
+ status: 'bad',
803
+ title: adminsWithout.length === 1
804
+ ? 'Un amministratore entra con la sola password'
805
+ : `${adminsWithout.length} amministratori entrano con la sola password`,
806
+ detail: `${adminsWithout.map((p) => p.name).join(', ')}: chi ruba quella password controlla tutto il sito. Attivate la verifica in due passaggi dal profilo → Sicurezza.`,
807
+ action: adminsWithout.some((p) => p.id === c.get('user').id) ? 'profile' : 'people',
808
+ }
809
+ : {
810
+ id: 'admins-2fa',
811
+ status: 'ok',
812
+ title: 'Gli amministratori usano la verifica in due passaggi',
813
+ detail: 'Per entrare servono password e codice dal telefono.',
814
+ });
815
+ checks.push(required
816
+ ? {
817
+ id: 'everyone-2fa',
818
+ status: 'ok',
819
+ title: 'Verifica in due passaggi richiesta a tutti',
820
+ detail: without.length
821
+ ? `${without.length === 1 ? 'Una persona la attiverà' : `${without.length} persone la attiveranno`} al prossimo accesso.`
822
+ : 'Tutti l’hanno attivata.',
823
+ }
824
+ : {
825
+ id: 'everyone-2fa',
826
+ status: without.length ? 'warn' : 'ok',
827
+ title: without.length
828
+ ? `${without.length === 1 ? 'Una persona entra' : `${without.length} persone entrano`} con la sola password`
829
+ : 'Tutti usano la verifica in due passaggi',
830
+ detail: without.length
831
+ ? 'Potete richiederla a tutti da Persone: chi non l’ha la attiverà al prossimo accesso.'
832
+ : 'Potete anche richiederla a tutti da Persone, per chi entrerà in futuro.',
833
+ action: without.length ? 'people' : undefined,
834
+ });
835
+ const mail = isConfigured(await settings.get('mail'));
836
+ checks.push(mail
837
+ ? {
838
+ id: 'mail',
839
+ status: 'ok',
840
+ title: 'Gli avvisi arrivano per email',
841
+ detail: 'Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.',
842
+ }
843
+ : {
844
+ id: 'mail',
845
+ status: 'warn',
846
+ title: 'L’invio delle email non è configurato',
847
+ detail: 'Nessuno viene avvisato di tentativi di accesso falliti o di un indirizzo cambiato, e i link per una nuova password vanno mandati a mano.',
848
+ action: 'mail',
849
+ });
850
+ const url = new URL(c.req.url);
851
+ const local = /^(localhost|127\.|\[::1\])/.test(url.hostname);
852
+ const secure = url.protocol === 'https:' ||
853
+ c.req.header('x-forwarded-proto') === 'https' ||
854
+ !!ctx.config().site.url?.startsWith('https:');
855
+ checks.push(local
856
+ ? {
857
+ id: 'https',
858
+ status: 'info',
859
+ title: 'Pannello aperto in locale',
860
+ detail: 'Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.',
861
+ }
862
+ : secure
863
+ ? {
864
+ id: 'https',
865
+ status: 'ok',
866
+ title: 'Connessione cifrata (HTTPS)',
867
+ detail: 'Password e sessioni viaggiano cifrate.',
868
+ }
869
+ : {
870
+ id: 'https',
871
+ status: 'bad',
872
+ title: 'Il pannello non usa HTTPS',
873
+ detail: 'Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.',
874
+ });
875
+ const forwarded = !!c.req.header('x-forwarded-for');
876
+ const trust = ctx.config().server.trustProxy;
877
+ if (forwarded && !trust)
878
+ checks.push({
879
+ id: 'proxy',
880
+ status: 'warn',
881
+ title: 'Il sito è dietro un proxy, ma non lo sa',
882
+ detail: 'Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: { trustProxy: true } in materialize.config.ts.',
883
+ });
884
+ else if (trust && !forwarded && !local)
885
+ checks.push({
886
+ id: 'proxy',
887
+ status: 'warn',
888
+ title: 'trustProxy è acceso, ma non arriva nessun proxy',
889
+ detail: 'Senza un proxy davanti, chiunque può dichiarare un indirizzo falso e aggirare i limiti agli accessi. Se il sito non è dietro un proxy, spegnilo in materialize.config.ts.',
890
+ });
891
+ const day = new Date(Date.now() - 24 * 60 * 60_000);
892
+ const failed = (await activityLog.countSince('login.failed', day)) + (await activityLog.countSince('login.code_failed', day));
893
+ const locked = await activityLog.lockedKeys();
894
+ checks.push({
895
+ id: 'failed',
896
+ status: failed >= 20 || locked.length ? 'warn' : 'ok',
897
+ title: failed
898
+ ? `${failed === 1 ? 'Un accesso fallito' : `${failed} accessi falliti`} nelle ultime 24 ore`
899
+ : 'Nessun accesso fallito nelle ultime 24 ore',
900
+ detail: locked.length
901
+ ? `In pausa adesso: ${locked
902
+ .map((k) => k
903
+ .replace(/^email:/, '')
904
+ .replace(/^ip:/, 'indirizzo ')
905
+ .replace(/^2fa:.*/, 'un codice di verifica'))
906
+ .join(', ')}. Le pause finiscono da sole.`
907
+ : 'Dopo 5 errori un’email va in pausa, dopo 20 un indirizzo: la pausa raddoppia a ogni nuovo errore.',
908
+ });
909
+ if (!backups.available())
910
+ checks.push({
911
+ id: 'backups',
912
+ status: 'info',
913
+ title: 'Database ospitato da un fornitore',
914
+ detail: 'I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.',
915
+ });
916
+ else {
917
+ const b = await backupSettings();
918
+ const last = (await backups.list())[0];
919
+ const age = last ? Date.now() - last.createdAt.getTime() : Number.POSITIVE_INFINITY;
920
+ checks.push(!b.enabled
921
+ ? {
922
+ id: 'backups',
923
+ status: 'warn',
924
+ title: 'Backup automatici spenti',
925
+ detail: last
926
+ ? `L’ultima copia è di ${Math.round(age / 86_400_000)} giorni fa. Riaccendili qui sotto.`
927
+ : 'Non c’è nessuna copia del database. Riaccendili qui sotto.',
928
+ action: 'backups',
929
+ }
930
+ : age > 2 * 86_400_000
931
+ ? {
932
+ id: 'backups',
933
+ status: last ? 'warn' : 'info',
934
+ title: last ? 'L’ultimo backup è vecchio' : 'Il primo backup arriva a breve',
935
+ detail: last
936
+ ? 'Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.'
937
+ : 'Il server fa una copia del database poco dopo l’avvio, poi una al giorno.',
938
+ action: 'backups',
939
+ }
940
+ : {
941
+ id: 'backups',
942
+ status: 'ok',
943
+ title: 'Backup del database ogni giorno',
944
+ 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.`,
945
+ });
946
+ }
947
+ const tokens = await activityLog.tokens();
948
+ const stale = tokens.filter((t) => Date.now() - (t.lastUsedAt ?? t.createdAt).getTime() > 90 * 24 * 60 * 60_000);
949
+ if (tokens.length)
950
+ checks.push({
951
+ id: 'tokens',
952
+ status: stale.length ? 'warn' : 'ok',
953
+ title: `${tokens.length === 1 ? 'Un token di accesso' : `${tokens.length} token di accesso`} per i client AI`,
954
+ detail: stale.length
955
+ ? `${stale.length === 1 ? 'Uno non è usato' : `${stale.length} non sono usati`} da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.`
956
+ : 'Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.',
957
+ });
958
+ return c.json({ checks });
959
+ });
960
+ // ---------- backups ----------
961
+ const backupState = async () => ({
962
+ ...(await backupSettings()),
963
+ available: backups.available(),
964
+ items: await backups.list(),
965
+ media: await backups.mediaCopy(),
966
+ });
967
+ app.get('/api/backups', requireUser, requireSession, requireAdmin, async (c) => c.json(await backupState()));
968
+ /**
969
+ * Everything in one .tar.gz: a fresh copy of the database, the original media and the fonts,
970
+ * written as it is downloaded. Works with a hosted database too, for the media.
971
+ */
972
+ app.get('/api/backups/archive', requireUser, requireSession, requireAdmin, async (c) => {
973
+ const work = await mkdtemp(join(tmpdir(), 'materialize-archive-'));
974
+ const entries = [];
975
+ if (backups.available()) {
976
+ const db = join(work, 'content.db');
977
+ await ctx.database.client.execute({ sql: 'VACUUM INTO ?', args: [db] });
978
+ entries.push({ name: 'content.db', path: db });
979
+ }
980
+ const mediaDir = resolve(ctx.config().media.dir);
981
+ entries.push(...(await folderEntries(mediaDir, 'media')));
982
+ entries.push(...(await folderEntries(join(dirname(mediaDir), 'fonts'), 'fonts')));
983
+ const manifest = join(work, 'materialize.json');
984
+ await writeFile(manifest, JSON.stringify({
985
+ site: ctx.config().site.name,
986
+ version: VERSION,
987
+ createdAt: new Date().toISOString(),
988
+ database: backups.available(),
989
+ }, null, 2));
990
+ entries.unshift({ name: 'materialize.json', path: manifest });
991
+ const stream = tarGz(entries);
992
+ stream.on('close', () => void rm(work, { recursive: true, force: true }));
993
+ stream.on('error', (error) => {
994
+ console.error('Materialize: archive not completed', error);
995
+ void rm(work, { recursive: true, force: true });
996
+ });
997
+ await record(c, 'backup.archive', '', { files: entries.length });
998
+ const d = new Date();
999
+ const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1000
+ return c.body(Readable.toWeb(stream), 200, {
1001
+ 'content-type': 'application/gzip',
1002
+ 'content-disposition': `attachment; filename="materialize-${day}.tar.gz"`,
1003
+ });
1004
+ });
1005
+ app.put('/api/backups', requireUser, requireSession, requireAdmin, async (c) => {
1006
+ const input = await body(c, z.object({ enabled: z.boolean(), keep: z.number().int().min(1).max(365) }));
1007
+ await settings.set('backups', input, c.get('user').id);
1008
+ await record(c, 'settings.backups', '', input);
1009
+ return c.json(await backupState());
1010
+ });
1011
+ app.post('/api/backups', requireUser, requireSession, requireAdmin, async (c) => {
1012
+ if (!backups.available())
1013
+ throw new ApiError(409, 'backups_unavailable', 'The database is hosted: its provider keeps the backups');
1014
+ const made = await backups.create();
1015
+ await backups.prune((await backupSettings()).keep);
1016
+ await backups.mirrorMedia();
1017
+ await record(c, 'backup.created', made.name);
1018
+ return c.json({ backup: made }, 201);
1019
+ });
1020
+ app.get('/api/backups/:name', requireUser, requireSession, requireAdmin, async (c) => {
1021
+ const path = await backups.path(c.req.param('name'));
1022
+ if (!path)
1023
+ throw new ApiError(404, 'not_found', 'There is no such backup');
1024
+ await record(c, 'backup.downloaded', c.req.param('name'));
1025
+ return c.body(await readFile(path), 200, {
1026
+ 'content-type': 'application/vnd.sqlite3',
1027
+ 'content-disposition': `attachment; filename="${c.req.param('name')}"`,
1028
+ });
1029
+ });
1030
+ app.delete('/api/backups/:name', requireUser, requireSession, requireAdmin, async (c) => {
1031
+ if (!(await backups.remove(c.req.param('name'))))
1032
+ throw new ApiError(404, 'not_found', 'There is no such backup');
1033
+ await record(c, 'backup.deleted', c.req.param('name'));
1034
+ return c.json({ ok: true });
1035
+ });
1036
+ // ---------- updates ----------
1037
+ const updateState = async () => {
1038
+ const status = ctx.updates
1039
+ ? await ctx.updates.status()
1040
+ : {
1041
+ current: VERSION,
1042
+ latest: null,
1043
+ notes: [],
1044
+ checkedAt: null,
1045
+ unavailable: 'Questo server non è avviato da materialize start: aggiorna da terminale.',
1046
+ previous: null,
1047
+ job: { state: 'idle' },
1048
+ last: null,
1049
+ };
1050
+ return { ...status, settings: await updateSettings() };
1051
+ };
1052
+ app.get('/api/updates', requireUser, requireSession, requireAdmin, async (c) => c.json(await updateState()));
1053
+ app.post('/api/updates/check', requireUser, requireSession, requireAdmin, async (c) => {
1054
+ await ctx.updates?.check();
1055
+ return c.json(await updateState());
1056
+ });
1057
+ app.post('/api/updates/install', requireUser, requireSession, requireAdmin, async (c) => {
1058
+ const input = await body(c, z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }));
1059
+ if (!ctx.updates)
1060
+ throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
1061
+ try {
1062
+ await ctx.updates.install(input.version, backupBeforeUpdate);
1063
+ }
1064
+ catch (error) {
1065
+ throw new ApiError(409, 'update_refused', error.message);
1066
+ }
1067
+ await record(c, 'update.install', input.version, { from: VERSION });
1068
+ return c.json(await updateState(), 202);
1069
+ });
1070
+ app.post('/api/updates/rollback', requireUser, requireSession, requireAdmin, async (c) => {
1071
+ if (!ctx.updates)
1072
+ throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
1073
+ try {
1074
+ await ctx.updates.rollback();
1075
+ }
1076
+ catch (error) {
1077
+ throw new ApiError(409, 'update_refused', error.message);
1078
+ }
1079
+ await record(c, 'update.rollback', (await ctx.updates.status()).previous ?? '', { from: VERSION });
1080
+ return c.json(await updateState(), 202);
1081
+ });
1082
+ app.put('/api/updates/settings', requireUser, requireSession, requireAdmin, async (c) => {
1083
+ const input = await body(c, z.object({ auto: z.boolean() }));
1084
+ await settings.set('updates', input, c.get('user').id);
1085
+ await record(c, 'settings.updates', input.auto ? 'auto' : 'manual');
1086
+ return c.json(await updateState());
1087
+ });
1088
+ app.put('/api/security', requireUser, requireSession, requireAdmin, async (c) => {
1089
+ const input = await body(c, z.object({ requireTwoFactor: z.boolean() }));
1090
+ if (input.requireTwoFactor && !c.get('user').twoFactor)
1091
+ throw new ApiError(409, 'two_factor_off', 'Turn on two-step verification for yourself first');
1092
+ await settings.set('security', input, c.get('user').id);
1093
+ await record(c, input.requireTwoFactor ? 'security.require_2fa' : 'security.optional_2fa');
1094
+ return c.json(input);
1095
+ });
1096
+ // ---------- personal access tokens (MCP clients, scripts) ----------
1097
+ app.get('/api/tokens', requireUser, requireSession, async (c) => c.json({ tokens: await auth.listTokens(c.get('user').id) }));
1098
+ app.post('/api/tokens', requireUser, requireSession, async (c) => {
1099
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(80) }));
1100
+ const created = await auth.createToken(c.get('user').id, input.name);
1101
+ await record(c, 'token.created', input.name);
1102
+ return c.json(created, 201);
1103
+ });
1104
+ app.delete('/api/tokens/:id', requireUser, requireSession, async (c) => {
1105
+ if (!(await auth.revokeToken(c.req.param('id'), c.get('user').id)))
1106
+ throw new ApiError(404, 'not_found', 'There is no such token');
1107
+ await record(c, 'token.revoked');
1108
+ return c.json({ ok: true });
1109
+ });
1110
+ app.patch('/api/auth/me', requireUser, async (c) => {
1111
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(80) }));
1112
+ return c.json({ user: await auth.updateUser(c.get('user').id, input) });
1113
+ });
1114
+ /** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
1115
+ const emailChanged = async (before, after, by) => {
1116
+ await mailNotice(before, 'Il tuo indirizzo email è cambiato', [
1117
+ `${by === 'self' ? 'Hai cambiato' : `${by} ha cambiato`} l’indirizzo con cui entri nel pannello del sito: da ${before} a ${after}.`,
1118
+ 'Se non te lo aspettavi, rispondi a questa email o avvisa un amministratore del sito.',
1119
+ ]);
1120
+ };
1121
+ // Changing one's own email needs the password: a session left open is not enough.
1122
+ app.post('/api/auth/email', requireUser, requireSession, async (c) => {
1123
+ const input = await body(c, z.object({ email: z.email(), password: z.string().min(1) }));
1124
+ const user = c.get('user');
1125
+ const hash = await auth.getPasswordHash(user.id);
1126
+ if (!hash || !(await verifyPassword(input.password, hash))) {
1127
+ throw new ApiError(422, 'wrong_password', 'The password is not correct', [
1128
+ { path: 'password', message: 'La password non è corretta' },
1129
+ ]);
1130
+ }
1131
+ try {
1132
+ const updated = await auth.updateUser(user.id, { email: input.email });
1133
+ if (updated && updated.email !== user.email) {
1134
+ await emailChanged(user.email, updated.email, 'self');
1135
+ await record(c, 'user.email', updated.email, { from: user.email });
1136
+ }
1137
+ return c.json({ user: updated });
1138
+ }
1139
+ catch (error) {
1140
+ if (error instanceof EmailTakenError)
1141
+ throw new ApiError(409, 'email_taken', error.message);
1142
+ throw error;
1143
+ }
1144
+ });
1145
+ app.post('/api/auth/password', requireUser, requireSession, async (c) => {
1146
+ const input = await body(c, z.object({ current: z.string().min(1), next: z.string().min(10) }));
1147
+ const user = c.get('user');
1148
+ const hash = await auth.getPasswordHash(user.id);
1149
+ if (!hash || !(await verifyPassword(input.current, hash))) {
1150
+ throw new ApiError(422, 'wrong_password', 'The current password is not correct', [
1151
+ { path: 'current', message: 'La password attuale non è corretta' },
1152
+ ]);
1153
+ }
1154
+ // Other devices are signed out; this one stays in.
1155
+ await auth.setPassword(user.id, await hashPassword(input.next), getCookie(c, SESSION_COOKIE));
1156
+ await record(c, 'user.password', user.email);
1157
+ return c.json({ ok: true });
1158
+ });
1159
+ // ---------- people (admins only) ----------
1160
+ const loadUser = async (id) => {
1161
+ const user = await auth.getUser(id);
1162
+ if (!user)
1163
+ throw new ApiError(404, 'not_found', 'This person does not exist');
1164
+ return user;
1165
+ };
1166
+ app.get('/api/users', requireUser, requireAdmin, async (c) => c.json({ users: await auth.listUsers(), invites: await auth.pendingInvites() }));
1167
+ app.post('/api/users/invites', requireUser, requireSession, requireAdmin, async (c) => {
1168
+ const input = await body(c, z.object({ email: z.email(), name: z.string().trim().max(80).optional(), role: roleSchema }));
1169
+ try {
1170
+ const { token, invite } = await auth.createInvite({
1171
+ kind: 'invite',
1172
+ ...input,
1173
+ createdBy: c.get('user').id,
1174
+ ttlMs: INVITE_TTL,
1175
+ });
1176
+ const link = linkPath(token);
1177
+ const mailed = await mailLink(c, input.email, 'Il tuo invito', [
1178
+ `${c.get('user').name} ti ha invitato a lavorare sul sito con Materialize.`,
1179
+ 'Apri questo link per scegliere la tua password ed entrare:',
1180
+ ], link);
1181
+ await record(c, 'user.invited', input.email, { role: input.role });
1182
+ return c.json({ invite, link, mailed }, 201);
1183
+ }
1184
+ catch (error) {
1185
+ if (error instanceof EmailTakenError)
1186
+ throw new ApiError(409, 'email_taken', error.message);
1187
+ throw error;
1188
+ }
1189
+ });
1190
+ app.delete('/api/users/invites/:id', requireUser, requireSession, requireAdmin, async (c) => {
1191
+ if (!(await auth.revokeInvite(c.req.param('id'))))
1192
+ throw new ApiError(404, 'not_found', 'Invitation not found');
1193
+ await record(c, 'user.invite_revoked');
1194
+ return c.body(null, 204);
1195
+ });
1196
+ app.patch('/api/users/:id', requireUser, requireSession, requireAdmin, async (c) => {
1197
+ const target = await loadUser(c.req.param('id'));
1198
+ const input = await body(c, z.object({
1199
+ role: roleSchema.optional(),
1200
+ name: z.string().trim().min(1).max(80).optional(),
1201
+ email: z.email().optional(),
1202
+ }));
1203
+ if (target.role === 'admin' && input.role === 'editor' && (await auth.countAdmins()) === 1) {
1204
+ throw new ApiError(409, 'last_admin', 'The site needs at least one administrator');
1205
+ }
1206
+ try {
1207
+ const updated = await auth.updateUser(target.id, input);
1208
+ if (updated && updated.email !== target.email) {
1209
+ await emailChanged(target.email, updated.email, c.get('user').name);
1210
+ await record(c, 'user.email', updated.email, { from: target.email });
1211
+ }
1212
+ if (updated && updated.role !== target.role)
1213
+ await record(c, 'user.role', updated.email, { role: updated.role });
1214
+ return c.json({ user: updated });
1215
+ }
1216
+ catch (error) {
1217
+ if (error instanceof EmailTakenError)
1218
+ throw new ApiError(409, 'email_taken', error.message);
1219
+ throw error;
1220
+ }
1221
+ });
1222
+ app.delete('/api/users/:id', requireUser, requireSession, requireAdmin, async (c) => {
1223
+ const target = await loadUser(c.req.param('id'));
1224
+ if (target.id === c.get('user').id)
1225
+ throw new ApiError(409, 'remove_self', 'You cannot remove yourself');
1226
+ if (target.role === 'admin' && (await auth.countAdmins()) === 1) {
1227
+ throw new ApiError(409, 'last_admin', 'The site needs at least one administrator');
1228
+ }
1229
+ await auth.deleteUser(target.id);
1230
+ await record(c, 'user.removed', target.email);
1231
+ return c.body(null, 204);
1232
+ });
1233
+ /** For someone who lost their phone and their recovery codes: they sign in with the password alone. */
1234
+ app.delete('/api/users/:id/two-factor', requireUser, requireSession, requireAdmin, async (c) => {
1235
+ const target = await loadUser(c.req.param('id'));
1236
+ await auth.disableTwoFactor(target.id);
1237
+ await mailNotice(target.email, 'Verifica in due passaggi disattivata', [
1238
+ `${c.get('user').name} ha disattivato la verifica in due passaggi del tuo account: per entrare nel pannello ora basta la password.`,
1239
+ 'Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.',
1240
+ ]);
1241
+ await record(c, 'twofactor.reset', target.email);
1242
+ return c.json({ user: await auth.getUser(target.id) });
1243
+ });
1244
+ app.post('/api/users/:id/reset', requireUser, requireSession, requireAdmin, async (c) => {
1245
+ const target = await loadUser(c.req.param('id'));
1246
+ const { token, invite } = await auth.createInvite({
1247
+ kind: 'reset',
1248
+ email: target.email,
1249
+ name: target.name,
1250
+ role: target.role,
1251
+ userId: target.id,
1252
+ createdBy: c.get('user').id,
1253
+ ttlMs: RESET_TTL,
1254
+ });
1255
+ const link = linkPath(token);
1256
+ const mailed = await mailLink(c, target.email, 'Nuova password', ['È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:'], link);
1257
+ await record(c, 'user.reset_link', target.email);
1258
+ return c.json({ invite, link, mailed }, 201);
1259
+ });
1260
+ // ---------- one-time links (no session needed) ----------
1261
+ const loadInvite = async (token) => {
1262
+ const invite = await auth.findInvite(token);
1263
+ if (!invite)
1264
+ throw new ApiError(404, 'invalid_link', 'This link is not valid or has expired');
1265
+ return invite;
1266
+ };
1267
+ app.get('/api/invites/:token', async (c) => {
1268
+ const { kind, email, name, role, expiresAt } = await loadInvite(c.req.param('token'));
1269
+ return c.json({ kind, email, name, role, expiresAt, site: ctx.config().site.name });
1270
+ });
1271
+ app.post('/api/invites/:token/accept', async (c) => {
1272
+ const token = c.req.param('token');
1273
+ const invite = await loadInvite(token);
1274
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(80).optional(), password: z.string().min(10) }));
1275
+ const passwordHash = await hashPassword(input.password);
1276
+ let user;
1277
+ if (invite.kind === 'reset' && invite.userId) {
1278
+ await auth.setPassword(invite.userId, passwordHash);
1279
+ user = await auth.getUser(invite.userId);
1280
+ }
1281
+ else {
1282
+ if (await auth.emailTaken(invite.email))
1283
+ throw new ApiError(409, 'email_taken', 'This email already has an account');
1284
+ user = await auth.createUser({
1285
+ email: invite.email,
1286
+ name: input.name || invite.name || invite.email.split('@')[0] || invite.email,
1287
+ role: invite.role,
1288
+ passwordHash,
1289
+ });
1290
+ }
1291
+ await auth.consumeInvite(token);
1292
+ if (!user)
1293
+ throw new ApiError(404, 'not_found', 'This person does not exist');
1294
+ await record(c, invite.kind === 'reset' ? 'user.password_reset' : 'user.joined', user.email, null, user);
1295
+ // A new password is not a way around the second step.
1296
+ return signIn(c, user, invite.kind === 'reset' ? 200 : 201);
1297
+ });
1298
+ // ---------- schema ----------
1299
+ app.get('/api/schema', requireUser, async (c) => {
1300
+ const config = ctx.config();
1301
+ return c.json({
1302
+ site: { ...config.site, name: await siteName() },
1303
+ preview: ctx.preview === true,
1304
+ locales: config.locales,
1305
+ collections: config.collections.map(describeCollection),
1306
+ });
1307
+ });
1308
+ // ---------- management API ----------
1309
+ // The site's design theme: colors, fonts and roundness used by pages built on the canvas.
1310
+ app.get('/api/theme', requireUser, async (c) => c.json({ theme: normalizeTheme(await settings.get('theme')) }));
1311
+ app.put('/api/theme', requireUser, requireAdmin, async (c) => {
1312
+ const input = await body(c, z.object({ theme: z.record(z.string(), z.unknown()) }));
1313
+ const theme = normalizeTheme(input.theme);
1314
+ await settings.set('theme', theme, c.get('user').id);
1315
+ ctx.onPublish?.();
1316
+ await record(c, 'settings.theme');
1317
+ return c.json({ theme });
1318
+ });
1319
+ // Sections shared by several pages (menu, footer): edited once, live everywhere.
1320
+ /** Where each global section appears: the titles of the pages that use it, draft or published. */
1321
+ const globalUsage = async () => {
1322
+ const out = {};
1323
+ const { collections, locales } = ctx.config();
1324
+ for (const def of collections) {
1325
+ const pageFields = Object.entries(def.fields).filter(([, f]) => f.kind === 'page');
1326
+ if (!pageFields.length)
1327
+ continue;
1328
+ for (const lang of locales.available) {
1329
+ const { items } = await content.list(def.name, { locale: lang, limit: 200 });
1330
+ for (const doc of await content.getMany(items.map((i) => i.id)))
1331
+ for (const [name] of pageFields)
1332
+ for (const data of [doc.data, doc.publishedData ?? {}]) {
1333
+ const nodes = data[name];
1334
+ if (!Array.isArray(nodes))
1335
+ continue;
1336
+ for (const n of nodes) {
1337
+ if (!n.global)
1338
+ continue;
1339
+ const titles = out[n.global] ?? [];
1340
+ if (!titles.includes(doc.title))
1341
+ out[n.global] = [...titles, doc.title];
1342
+ }
1343
+ }
1344
+ }
1345
+ }
1346
+ return out;
1347
+ };
1348
+ /**
1349
+ * Global sections in a language: the main language's structure and design with that language's
1350
+ * words (saved apart, under "globals:<locale>").
1351
+ */
1352
+ const globalsIn = async (lang) => {
1353
+ const main = (await settings.get('globals')) ?? {};
1354
+ if (!lang || lang === ctx.config().locales.default)
1355
+ return main;
1356
+ const words = (await settings.get(`globals:${lang}`)) ?? {};
1357
+ return Object.fromEntries(Object.entries(main).map(([id, n]) => [id, words[id] ? mergeTranslation([n], [words[id]])[0] : n]));
1358
+ };
1359
+ app.get('/api/globals', requireUser, async (c) => {
1360
+ await languagesLoaded;
1361
+ const lang = c.req.query('locale');
1362
+ return c.json({ globals: await globalsIn(lang ? locale(lang) : undefined), usage: await globalUsage() });
1363
+ });
1364
+ app.put('/api/globals', requireUser, async (c) => {
1365
+ const input = await body(c, z.object({ globals: z.record(z.string(), z.record(z.string(), z.unknown())), locale: z.string().optional() }));
1366
+ await languagesLoaded;
1367
+ const lang = input.locale ? locale(input.locale) : ctx.config().locales.default;
1368
+ if (lang !== ctx.config().locales.default) {
1369
+ // Another language changes only the words: structure and design stay the main language's.
1370
+ const main = (await settings.get('globals')) ?? {};
1371
+ const words = Object.fromEntries(Object.entries(input.globals)
1372
+ .filter(([id]) => main[id])
1373
+ .map(([id, n]) => [id, n]));
1374
+ await settings.set(`globals:${lang}`, words, c.get('user').id);
1375
+ ctx.onPublish?.();
1376
+ return c.json({ globals: await globalsIn(lang) });
1377
+ }
1378
+ const before = (await settings.get('globals')) ?? {};
1379
+ guardEmbeds(c.get('user'), before, input.globals);
1380
+ const removed = Object.keys(before).filter((id) => !input.globals[id]);
1381
+ if (removed.length) {
1382
+ const usage = await globalUsage();
1383
+ const inUse = removed.find((id) => usage[id]?.length);
1384
+ if (inUse)
1385
+ throw new ApiError(409, 'global_in_use', `La sezione è ancora in ${usage[inUse]?.map((t) => `“${t}”`).join(', ')}: toglila prima dalle pagine`);
1386
+ }
1387
+ const ids = [...new Set([...Object.keys(before), ...Object.keys(input.globals)])].sort();
1388
+ await guardDesign(c.get('user'), ids.map((id) => before[id] ?? { id, type: 'frame' }), ids.map((id) => input.globals[id] ?? { id, type: 'frame' }));
1389
+ await settings.set('globals', input.globals, c.get('user').id);
1390
+ ctx.onPublish?.();
1391
+ return c.json({ globals: input.globals });
1392
+ });
1393
+ // ---------- comments and presence ----------
1394
+ app.get('/api/documents/:id/comments', requireUser, async (c) => c.json({ items: await comments.list(c.req.param('id')) }));
1395
+ app.post('/api/documents/:id/comments', requireUser, async (c) => {
1396
+ const input = await body(c, z.object({ nodeId: z.string().max(200).nullable(), body: z.string().trim().min(1).max(4000) }));
1397
+ const user = c.get('user');
1398
+ const comment = await comments.add({
1399
+ documentId: c.req.param('id'),
1400
+ nodeId: input.nodeId,
1401
+ authorId: user.id,
1402
+ authorName: user.name,
1403
+ body: input.body,
1404
+ });
1405
+ return c.json({ comment }, 201);
1406
+ });
1407
+ app.patch('/api/comments/:id', requireUser, async (c) => {
1408
+ const input = await body(c, z.object({ resolved: z.boolean() }));
1409
+ await comments.resolve(c.req.param('id'), input.resolved);
1410
+ return c.json({ ok: true });
1411
+ });
1412
+ app.delete('/api/comments/:id', requireUser, async (c) => {
1413
+ const comment = await comments.get(c.req.param('id'));
1414
+ if (!comment)
1415
+ throw new ApiError(404, 'not_found', 'Comment not found');
1416
+ const user = c.get('user');
1417
+ if (comment.authorId !== user.id && user.role !== 'admin')
1418
+ throw new ApiError(403, 'forbidden', 'Only the author or an admin can delete a comment');
1419
+ await comments.remove(comment.id);
1420
+ return c.body(null, 204);
1421
+ });
1422
+ app.post('/api/documents/:id/presence', requireUser, async (c) => {
1423
+ const input = await body(c, z.object({ selected: z.string().max(200).nullable() }));
1424
+ const user = c.get('user');
1425
+ const docId = c.req.param('id');
1426
+ const now = Date.now();
1427
+ const here = presence.get(docId) ?? new Map();
1428
+ here.set(user.id, { id: user.id, name: user.name, selected: input.selected, at: now });
1429
+ presence.set(docId, here);
1430
+ for (const [k, p] of here)
1431
+ if (now - p.at > 30_000)
1432
+ here.delete(k);
1433
+ return c.json({ others: [...here.values()].filter((p) => p.id !== user.id).map(({ at: _, ...p }) => p) });
1434
+ });
1435
+ // ---------- email and forms ----------
1436
+ /** Sends a one-time link by email when email is set up; false when it is not (the link is shown instead). */
1437
+ /** Sends a notice by email when email is set up; false when it is not. */
1438
+ const mailNotice = async (to, subject, lines) => {
1439
+ const mail = await settings.get('mail');
1440
+ if (!isConfigured(mail))
1441
+ return false;
1442
+ try {
1443
+ await sendMail(mail, { to, subject, text: `${lines.join('\n\n')}\n` });
1444
+ return true;
1445
+ }
1446
+ catch (error) {
1447
+ console.error('Materialize: email not sent', error);
1448
+ return false;
1449
+ }
1450
+ };
1451
+ const mailLink = async (c, to, subject, lines, path) => {
1452
+ const mail = await settings.get('mail');
1453
+ if (!isConfigured(mail))
1454
+ return false;
1455
+ try {
1456
+ const url = new URL(path, new URL(c.req.url).origin).toString();
1457
+ await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
1458
+ return true;
1459
+ }
1460
+ catch (error) {
1461
+ console.error('Materialize: email not sent', error);
1462
+ return false;
1463
+ }
1464
+ };
1465
+ app.get('/api/mail', requireUser, requireSession, requireAdmin, async (c) => c.json({ mail: publicMail(await settings.get('mail')) }));
1466
+ app.put('/api/mail', requireUser, requireSession, requireAdmin, async (c) => {
1467
+ const input = await body(c, z.object({
1468
+ mail: z.object({
1469
+ host: z.string().max(200),
1470
+ port: z.number().int().min(1).max(65535),
1471
+ security: z.enum(['tls', 'starttls', 'none']),
1472
+ user: z.string().max(200),
1473
+ // Empty: keep the saved password. null: remove it.
1474
+ pass: z.string().max(500).nullable().optional(),
1475
+ from: z.string().max(200),
1476
+ to: z.string().max(200),
1477
+ }),
1478
+ }));
1479
+ const before = await settings.get('mail');
1480
+ const pass = input.mail.pass === null ? '' : input.mail.pass || before?.pass || '';
1481
+ const mail = { ...input.mail, pass };
1482
+ await settings.set('mail', mail, c.get('user').id);
1483
+ await record(c, 'settings.mail');
1484
+ return c.json({ mail: publicMail(mail) });
1485
+ });
1486
+ app.post('/api/mail/test', requireUser, requireSession, requireAdmin, async (c) => {
1487
+ const mail = await settings.get('mail');
1488
+ if (!isConfigured(mail))
1489
+ throw new ApiError(400, 'mail_not_configured', 'Email is not set up');
1490
+ const to = mail.to || c.get('user').email;
1491
+ try {
1492
+ await sendMail(mail, {
1493
+ to,
1494
+ subject: 'Prova di invio da Materialize',
1495
+ text: 'Se leggi questo messaggio, l’invio delle email dal sito funziona.',
1496
+ });
1497
+ }
1498
+ catch (error) {
1499
+ throw new ApiError(422, 'mail_failed', error instanceof Error ? error.message : 'Sending failed');
1500
+ }
1501
+ return c.json({ ok: true, to });
1502
+ });
1503
+ // A form on the site: the message is kept, and sent by email when email is set up.
1504
+ // ---------- AI: settings, text actions and the editor's assistant ----------
1505
+ const aiSettings = async () => ({ ...EMPTY_AI, ...(await settings.get('ai')) });
1506
+ const readyAi = async () => {
1507
+ const ai = await aiSettings();
1508
+ if (!isReady(ai))
1509
+ throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
1510
+ return ai;
1511
+ };
1512
+ const aiFailed = (error) => {
1513
+ if (error instanceof AiError)
1514
+ throw new ApiError(422, 'ai_failed', error.message);
1515
+ throw error;
1516
+ };
1517
+ const allowAi = createLimiter(40, 60_000);
1518
+ app.get('/api/ai', requireUser, async (c) => {
1519
+ const ai = await aiSettings();
1520
+ return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai) } : { ai: { ready: isReady(ai) } });
1521
+ });
1522
+ app.put('/api/ai', requireUser, requireSession, requireAdmin, async (c) => {
1523
+ const input = await body(c, z.object({
1524
+ ai: z.object({
1525
+ provider: z.enum(['anthropic', 'openai']),
1526
+ // Empty: keep the saved key. null: remove it.
1527
+ apiKey: z.string().max(500).nullable().optional(),
1528
+ baseUrl: z.union([z.url().max(500), z.literal('')]),
1529
+ model: z.string().trim().max(120),
1530
+ }),
1531
+ }));
1532
+ const before = await aiSettings();
1533
+ const apiKey = input.ai.apiKey === null ? '' : input.ai.apiKey?.trim() || before.apiKey;
1534
+ const ai = { ...input.ai, apiKey };
1535
+ await settings.set('ai', ai, c.get('user').id);
1536
+ await record(c, 'settings.ai', ai.provider);
1537
+ return c.json({ ai: publicAi(ai) });
1538
+ });
1539
+ app.post('/api/ai/test', requireUser, requireSession, requireAdmin, async (c) => {
1540
+ const ai = await readyAi();
1541
+ const reply = await complete(ai, { system: 'Answer with one short word.', prompt: 'Say: ok' }).catch(aiFailed);
1542
+ return c.json({ ok: true, reply });
1543
+ });
1544
+ const TEXT_ACTIONS = {
1545
+ rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1546
+ shorten: 'Make it shorter, keeping what matters.',
1547
+ expand: 'Make it a little longer and richer, without inventing facts.',
1548
+ fix: 'Fix spelling, grammar and punctuation only; change nothing else.',
1549
+ formal: 'Make the tone more formal and professional.',
1550
+ friendly: 'Make the tone warmer and more conversational.',
1551
+ seo_title: 'Write a page title for search engines, at most 60 characters, from this page content.',
1552
+ seo_description: 'Write a meta description for search engines, 120 to 160 characters, from this page content: what the page offers, with a reason to click.',
1553
+ };
1554
+ app.post('/api/ai/text', requireUser, async (c) => {
1555
+ const input = await body(c, z.object({
1556
+ action: z.enum(['translate', 'custom', 'alt', ...Object.keys(TEXT_ACTIONS)]),
1557
+ text: z.string().max(40_000).default(''),
1558
+ /** translate: the language to translate into. custom: what to do. */
1559
+ instruction: z.string().trim().max(500).optional(),
1560
+ /** alt: the picture (an address in the media library). */
1561
+ src: z.string().max(1000).optional(),
1562
+ /** The site's language, for texts written from scratch. */
1563
+ locale: z.string().max(20).optional(),
1564
+ }));
1565
+ if (!allowAi(c.get('user').id))
1566
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1567
+ const ai = await readyAi();
1568
+ const lang = input.locale ?? ctx.config().locales.default;
1569
+ const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.`;
1570
+ if (input.action === 'alt') {
1571
+ const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
1572
+ const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
1573
+ if (!file)
1574
+ throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
1575
+ const data = (await readFile(file.path)).toString('base64');
1576
+ const text = await complete(ai, {
1577
+ system,
1578
+ 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".`,
1579
+ image: { data, mime: 'image/jpeg' },
1580
+ }).catch(aiFailed);
1581
+ return c.json({ text });
1582
+ }
1583
+ if (!input.text.trim())
1584
+ throw new ApiError(400, 'empty_text', 'Non c’è testo su cui lavorare');
1585
+ const task = input.action === 'translate'
1586
+ ? `Translate it into ${input.instruction || 'English'}, naturally, as a native writer would.`
1587
+ : input.action === 'custom'
1588
+ ? (input.instruction ?? 'Improve it.')
1589
+ : TEXT_ACTIONS[input.action];
1590
+ const text = await complete(ai, { system, prompt: `${task}\n\n<text>\n${input.text}\n</text>` }).catch(aiFailed);
1591
+ return c.json({ text });
1592
+ });
1593
+ /** Conversations with the assistant, per person and chat, kept for two hours in memory. */
1594
+ const chats = new Map();
1595
+ app.post('/api/ai/chat', requireUser, async (c) => {
1596
+ const input = await body(c, z.object({
1597
+ chatId: z.string().max(64).optional(),
1598
+ message: z.string().trim().min(1).max(8000),
1599
+ /** The page as the editor holds it (shared sections and components expanded). */
1600
+ nodes: z.array(z.record(z.string(), z.unknown())).max(400),
1601
+ page: z.object({
1602
+ title: z.string().max(300),
1603
+ collection: z.string().max(100),
1604
+ url: z.string().max(500).nullish(),
1605
+ }),
1606
+ selected: z.array(z.string().max(100)).max(50).optional(),
1607
+ }));
1608
+ const user = c.get('user');
1609
+ if (!allowAi(user.id))
1610
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1611
+ const ai = await readyAi();
1612
+ const now = Date.now();
1613
+ for (const [k, v] of chats)
1614
+ if (now - v.at > 2 * 60 * 60 * 1000)
1615
+ chats.delete(k);
1616
+ const chatId = input.chatId ?? crypto.randomUUID();
1617
+ const key = `${user.id}:${chatId}`;
1618
+ const entry = chats.get(key);
1619
+ const conv = entry && entry.conv.provider === ai.provider ? entry.conv : { provider: ai.provider, messages: [] };
1620
+ chats.set(key, { conv, at: now });
1621
+ const before = input.nodes;
1622
+ const page = { nodes: before };
1623
+ const auth = {};
1624
+ const authorization = c.req.header('authorization');
1625
+ const cookie = c.req.header('cookie');
1626
+ if (authorization)
1627
+ auth.authorization = authorization;
1628
+ else if (cookie)
1629
+ auth.cookie = cookie;
1630
+ const tools = assistantTools(apiCaller(app.fetch, auth), page, ctx.config().site.name);
1631
+ const limited = await contentOnly(user);
1632
+ 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}` : ''}).
1633
+ 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.
1634
+ 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.
1635
+ ${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.
1636
+ ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).` : ''}`;
1637
+ return streamSSE(c, async (stream) => {
1638
+ const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
1639
+ try {
1640
+ const reply = await runTurn(ai, conv, {
1641
+ system,
1642
+ text: input.message,
1643
+ tools,
1644
+ onEvent: (e) => void send(e),
1645
+ });
1646
+ chats.set(key, { conv, at: Date.now() });
1647
+ if (page.nodes !== before) {
1648
+ if (limited && !sameDesign(before, page.nodes))
1649
+ await send({
1650
+ type: 'error',
1651
+ message: 'Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.',
1652
+ });
1653
+ else
1654
+ await send({ type: 'page', nodes: page.nodes });
1655
+ }
1656
+ await send({ type: 'text', text: reply });
1657
+ await send({ type: 'done', chatId });
1658
+ }
1659
+ catch (error) {
1660
+ if (!(error instanceof AiError))
1661
+ console.error(error);
1662
+ // A failed turn leaves the conversation as it was before it.
1663
+ chats.delete(key);
1664
+ await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
1665
+ await send({ type: 'done', chatId });
1666
+ }
1667
+ });
1668
+ });
1669
+ app.post('/api/forms/:form', async (c) => {
1670
+ const form = c.req.param('form').slice(0, 80);
1671
+ const ip = clientIp(c);
1672
+ if (!allowForm(`${ip}:${form}`))
1673
+ throw new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
1674
+ const isJson = (c.req.header('content-type') ?? '').includes('application/json');
1675
+ const raw = (isJson ? await c.req.json().catch(() => ({})) : await c.req.parseBody());
1676
+ const page = typeof raw._page === 'string' ? raw._page.slice(0, 300) : (c.req.header('referer') ?? '').slice(0, 300);
1677
+ // A field people never see: bots fill it in.
1678
+ const trap = typeof raw._gotcha === 'string' && raw._gotcha.length > 0;
1679
+ const data = {};
1680
+ for (const [k, v] of Object.entries(raw)) {
1681
+ if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
1682
+ continue;
1683
+ data[k.slice(0, 80)] = v.slice(0, 5000);
1684
+ }
1685
+ if (!trap && Object.values(data).some((v) => v.trim())) {
1686
+ const saved = await submissions.add({ form, page, data });
1687
+ const mail = await settings.get('mail');
1688
+ if (isConfigured(mail) && mail.to) {
1689
+ const replyTo = Object.entries(data).find(([k, v]) => /mail/i.test(k) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
1690
+ sendMail(mail, {
1691
+ to: mail.to,
1692
+ subject: `Nuovo messaggio dal sito${typeof raw._subject === 'string' ? `: ${raw._subject.slice(0, 120)}` : ''}`,
1693
+ text: submissionText(page, data),
1694
+ replyTo,
1695
+ })
1696
+ .then(() => submissions.markMailed(saved.id))
1697
+ .catch((error) => console.error('Materialize: email not sent', error));
1698
+ }
1699
+ }
1700
+ // Without JavaScript the browser posted the form itself: back to the page, marked as sent.
1701
+ if (!isJson) {
1702
+ const back = (c.req.header('referer') ?? '/').split('#')[0];
1703
+ return c.redirect(`${back}#inviato`, 303);
1704
+ }
1705
+ return c.json({ ok: true });
1706
+ });
1707
+ app.get('/api/submissions', requireUser, async (c) => {
1708
+ const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
1709
+ const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
1710
+ return c.json(await submissions.list(limit, offset));
1711
+ });
1712
+ app.patch('/api/submissions/:id', requireUser, async (c) => {
1713
+ const input = await body(c, z.object({ read: z.boolean() }));
1714
+ await submissions.markRead(c.req.param('id'), input.read);
1715
+ return c.json({ ok: true });
1716
+ });
1717
+ app.delete('/api/submissions/:id', requireUser, requireAdmin, async (c) => {
1718
+ await submissions.remove(c.req.param('id'));
1719
+ await record(c, 'message.deleted');
1720
+ return c.body(null, 204);
1721
+ });
1722
+ // Who may change what: editors limited to contents, if the admins want so.
1723
+ app.get('/api/editorial', requireUser, async (c) => c.json({ editorial: (await settings.get('editorial')) ?? {} }));
1724
+ app.put('/api/editorial', requireUser, requireSession, requireAdmin, async (c) => {
1725
+ const input = await body(c, z.object({ editorial: z.object({ editorsContentOnly: z.boolean().optional() }) }));
1726
+ await settings.set('editorial', input.editorial, c.get('user').id);
1727
+ await record(c, 'settings.editorial', '', { contentOnly: !!input.editorial.editorsContentOnly });
1728
+ return c.json({ editorial: input.editorial });
1729
+ });
1730
+ // Analytics, tags and the cookie banner on the published site. Third-party code: admins only.
1731
+ app.get('/api/tracking', requireUser, requireAdmin, async (c) => c.json({ tracking: (await settings.get('tracking')) ?? {} }));
1732
+ app.put('/api/tracking', requireUser, requireSession, requireAdmin, async (c) => {
1733
+ const id = (pattern, message) => z
1734
+ .string()
1735
+ .trim()
1736
+ .optional()
1737
+ .refine((v) => !v || pattern.test(v), message);
1738
+ const category = z.enum(['necessary', 'analytics', 'marketing']);
1739
+ const input = await body(c, z.object({
1740
+ tracking: z.object({
1741
+ ga4: id(TRACKING_PATTERNS.ga4, 'Un ID di Google Analytics 4 inizia con G-'),
1742
+ gtm: id(TRACKING_PATTERNS.gtm, 'Un ID di Tag Manager inizia con GTM-'),
1743
+ gtmCategory: category.optional(),
1744
+ metaPixel: id(TRACKING_PATTERNS.metaPixel, 'L’ID del Pixel è fatto di sole cifre'),
1745
+ plausible: id(TRACKING_PATTERNS.plausible, 'Scrivi il dominio come su Plausible, per esempio example.com'),
1746
+ plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, 'Un indirizzo https://'),
1747
+ matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, 'L’indirizzo di Matomo, con https://'),
1748
+ matomoSiteId: id(TRACKING_PATTERNS.matomoSiteId, 'Il numero del sito su Matomo'),
1749
+ custom: z
1750
+ .array(z.object({
1751
+ id: z.string().min(1).max(40),
1752
+ name: z.string().trim().min(1).max(80),
1753
+ place: z.enum(['head', 'body']),
1754
+ category,
1755
+ code: z
1756
+ .string()
1757
+ .max(20_000)
1758
+ .refine((v) => !/<\/template/i.test(v), 'Il codice non può contenere </template>'),
1759
+ }))
1760
+ .max(30)
1761
+ .optional(),
1762
+ banner: z
1763
+ .object({
1764
+ title: z.string().trim().max(120).optional(),
1765
+ text: z.string().trim().max(600).optional(),
1766
+ policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, 'Un indirizzo che inizia con / o https://'),
1767
+ position: z.enum(['bottom', 'corner']).optional(),
1768
+ reopenButton: z.boolean().optional(),
1769
+ })
1770
+ .optional(),
1771
+ }),
1772
+ }));
1773
+ await settings.set('tracking', input.tracking, c.get('user').id);
1774
+ ctx.onPublish?.();
1775
+ await record(c, 'settings.tracking', '', {
1776
+ services: trackingBlocks(input.tracking).map((b) => b.name),
1777
+ });
1778
+ return c.json({ tracking: input.tracking });
1779
+ });
1780
+ // Search engines: site-wide settings and redirects from old addresses.
1781
+ app.get('/api/seo', requireUser, async (c) => c.json({ seo: (await settings.get('seo')) ?? {} }));
1782
+ app.put('/api/seo', requireUser, requireAdmin, async (c) => {
1783
+ const input = await body(c, z.object({ seo: siteSeoSchema }));
1784
+ await settings.set('seo', input.seo, c.get('user').id);
1785
+ ctx.onPublish?.();
1786
+ await record(c, 'settings.seo');
1787
+ return c.json({ seo: input.seo });
1788
+ });
1789
+ // Every address on the site: published pages and old addresses that redirect, to check links.
1790
+ app.get('/api/urls', requireUser, async (c) => {
1791
+ const urls = [];
1792
+ for (const def of ctx.config().collections) {
1793
+ if (!def.url)
1794
+ continue;
1795
+ for (const doc of await content.published(def.name)) {
1796
+ const url = doc.publishedData ? publicUrl(def, doc.locale, doc.publishedData) : null;
1797
+ if (url)
1798
+ urls.push(url);
1799
+ }
1800
+ }
1801
+ const redirects = (await settings.get('redirects')) ?? [];
1802
+ return c.json({ urls: [...new Set([...urls, ...redirects.map((r) => r.from)])] });
1803
+ });
1804
+ app.get('/api/redirects', requireUser, async (c) => c.json({ redirects: (await settings.get('redirects')) ?? [] }));
1805
+ app.put('/api/redirects', requireUser, requireAdmin, async (c) => {
1806
+ const input = await body(c, z.object({ redirects: z.array(redirectSchema).max(2000) }));
1807
+ const redirects = input.redirects
1808
+ .map((r) => ({ ...r, from: normalizePath(r.from), to: /^https?:\/\//.test(r.to) ? r.to : normalizePath(r.to) }))
1809
+ .filter((r, i, all) => r.from !== r.to && all.findIndex((o) => o.from === r.from) === i);
1810
+ await settings.set('redirects', redirects, c.get('user').id);
1811
+ ctx.onPublish?.();
1812
+ await record(c, 'settings.redirects', '', { count: redirects.length });
1813
+ return c.json({ redirects });
1814
+ });
1815
+ // Template pages: a page designed on the canvas that shows each entry of a collection.
1816
+ app.get('/api/templates', requireUser, async (c) => c.json({ templates: (await settings.get('templates')) ?? {} }));
1817
+ app.put('/api/templates', requireUser, async (c) => {
1818
+ const input = await body(c, z.object({ templates: z.record(z.string(), z.string()) }));
1819
+ await settings.set('templates', input.templates, c.get('user').id);
1820
+ ctx.onPublish?.();
1821
+ return c.json({ templates: input.templates });
1822
+ });
1823
+ // Reusable components with their variants.
1824
+ app.get('/api/components', requireUser, async (c) => c.json({ components: (await settings.get('components')) ?? {} }));
1825
+ app.put('/api/components', requireUser, async (c) => {
1826
+ const input = await body(c, z.object({ components: z.record(z.string(), z.record(z.string(), z.unknown())) }));
1827
+ const before = (await settings.get('components')) ?? {};
1828
+ guardEmbeds(c.get('user'), before, input.components);
1829
+ if (JSON.stringify(before) !== JSON.stringify(input.components) && (await contentOnly(c.get('user'))))
1830
+ throw designLocked();
1831
+ await settings.set('components', input.components, c.get('user').id);
1832
+ ctx.onPublish?.();
1833
+ return c.json({ components: input.components });
1834
+ });
1835
+ // Entries with their data, for lists bound to a collection on the canvas.
1836
+ app.get('/api/collections/:name/entries', requireUser, async (c) => {
1837
+ const def = collection(c.req.param('name'));
1838
+ const { items } = await content.list(def.name, {
1839
+ locale: locale(c.req.query('locale')),
1840
+ limit: Math.min(Number(c.req.query('limit') ?? 24), 100),
1841
+ });
1842
+ const docs = await content.getMany(items.map((i) => i.id));
1843
+ const byId = new Map(docs.map((d) => [d.id, d]));
1844
+ // Readable values for the canvas: a choice's label, a related entry's title.
1845
+ const relationIds = docs.flatMap((d) => Object.entries(def.fields).flatMap(([name, f]) => {
1846
+ if (f.kind !== 'relation')
1847
+ return [];
1848
+ const v = d.data[name];
1849
+ return (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string');
1850
+ }));
1851
+ const related = new Map((await content.getMany([...new Set(relationIds)])).map((d) => [d.id, d.title]));
1852
+ const labelsOf = (data) => {
1853
+ const labels = {};
1854
+ for (const [name, f] of Object.entries(def.fields)) {
1855
+ const v = data[name];
1856
+ if (f.kind === 'select' && typeof v === 'string') {
1857
+ const opts = f.options.options ?? [];
1858
+ const hit = opts.find((o) => (typeof o === 'string' ? o : o.value) === v);
1859
+ if (hit && typeof hit !== 'string' && hit.label)
1860
+ labels[name] = hit.label;
1861
+ }
1862
+ if (f.kind === 'relation') {
1863
+ const ids = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string');
1864
+ const titles = ids.map((id) => related.get(id)).filter(Boolean);
1865
+ if (titles.length)
1866
+ labels[name] = titles.join(', ');
1867
+ }
1868
+ }
1869
+ return labels;
1870
+ };
1871
+ const entryLocale = locale(c.req.query('locale'));
1872
+ return c.json({
1873
+ items: items.flatMap((i) => {
1874
+ const doc = byId.get(i.id);
1875
+ return doc
1876
+ ? [
1877
+ {
1878
+ id: doc.id,
1879
+ title: doc.title,
1880
+ url: def.url ? def.url(doc.data) : null,
1881
+ data: doc.data,
1882
+ labels: labelsOf(doc.data),
1883
+ locale: entryLocale,
1884
+ },
1885
+ ]
1886
+ : [];
1887
+ }),
1888
+ });
1889
+ });
1890
+ app.get('/api/overview', requireUser, async (c) => {
1891
+ const known = new Set(ctx.config().collections.map((col) => col.name));
1892
+ const overview = await content.overview(locale(c.req.query('locale')));
1893
+ const people = new Map((await auth.listUsers()).map((u) => [u.id, u.name]));
1894
+ return c.json({
1895
+ counts: overview.counts.filter((row) => known.has(row.collection)),
1896
+ recent: overview.recent
1897
+ .filter((row) => known.has(row.collection))
1898
+ .map(({ updatedBy, ...row }) => ({ ...row, updatedBy: updatedBy ? (people.get(updatedBy) ?? null) : null })),
1899
+ media: (await mediaStore.list({ limit: 1 })).total,
1900
+ });
1901
+ });
1902
+ app.get('/api/collections/:name', requireUser, async (c) => {
1903
+ const def = collection(c.req.param('name'));
1904
+ const result = await content.list(def.name, {
1905
+ locale: locale(c.req.query('locale')),
1906
+ search: c.req.query('search') || undefined,
1907
+ limit: Number(c.req.query('limit') ?? 50),
1908
+ offset: Number(c.req.query('offset') ?? 0),
1909
+ });
1910
+ // The public address of each row (the home page is "/", not "/home"), when the slug is enough.
1911
+ const loc = locale(c.req.query('locale'));
1912
+ const urlOf = (slug) => {
1913
+ try {
1914
+ return publicUrl(def, loc, { slug });
1915
+ }
1916
+ catch {
1917
+ return null;
1918
+ }
1919
+ };
1920
+ return c.json({ ...result, items: result.items.map((i) => ({ ...i, url: urlOf(i.slug) })) });
1921
+ });
1922
+ app.post('/api/collections/:name', requireUser, async (c) => {
1923
+ const def = collection(c.req.param('name'));
1924
+ const input = await body(c, documentBody);
1925
+ const lang = locale(input.locale);
1926
+ if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0) {
1927
+ throw new ApiError(409, 'singleton_exists', `"${def.label}" already exists; edit it instead`);
1928
+ }
1929
+ const draft = draftData(def, input.data);
1930
+ guardEmbeds(c.get('user'), null, draft.data);
1931
+ const doc = await slugGuard(() => content.create({ collection: def.name, locale: lang, ...draft, userId: c.get('user').id }));
1932
+ await record(c, 'document.created', doc.title, { collection: def.name, id: doc.id });
1933
+ return c.json(toJson(doc), 201);
1934
+ });
1935
+ const loadDocument = async (id) => {
1936
+ const doc = await content.get(id);
1937
+ if (!doc)
1938
+ throw new ApiError(404, 'not_found', 'This document does not exist');
1939
+ return doc;
1940
+ };
1941
+ app.get('/api/documents/:id', requireUser, async (c) => c.json(toJson(await loadDocument(c.req.param('id')))));
1942
+ app.put('/api/documents/:id', requireUser, async (c) => {
1943
+ const existing = await loadDocument(c.req.param('id'));
1944
+ const input = await body(c, documentBody);
1945
+ const def = collection(existing.collection);
1946
+ const draft = draftData(def, input.data);
1947
+ guardEmbeds(c.get('user'), existing.data, draft.data);
1948
+ for (const [name, f] of Object.entries(def.fields))
1949
+ if (f.kind === 'page')
1950
+ await guardDesign(c.get('user'), existing.data[name], draft.data[name]);
1951
+ const doc = await slugGuard(() => content.update(existing.id, { ...draft, userId: c.get('user').id }));
1952
+ return c.json(toJson(doc));
1953
+ });
1954
+ app.post('/api/documents/:id/publish', requireUser, async (c) => {
1955
+ const existing = await loadDocument(c.req.param('id'));
1956
+ // Validated now, not at save time: drafts may be incomplete and the schema may have changed since.
1957
+ const def = collection(existing.collection);
1958
+ const data = publishableData(def, existing.data);
1959
+ // A published page that moves keeps its old address working: a permanent redirect to the new one.
1960
+ const before = existing.publishedData ? publicUrl(def, existing.locale, existing.publishedData) : null;
1961
+ const after = publicUrl(def, existing.locale, data);
1962
+ if (after) {
1963
+ // A page now living at an old address takes it back from its redirect.
1964
+ const redirects = (await settings.get('redirects')) ?? [];
1965
+ const next = before && before !== after
1966
+ ? addMove(redirects, before, after)
1967
+ : redirects.filter((r) => normalizePath(r.from) !== normalizePath(after));
1968
+ if (next.length !== redirects.length || next.some((r, i) => r !== redirects[i]))
1969
+ await settings.set('redirects', next, c.get('user').id);
1970
+ }
1971
+ const doc = (await content.publish(existing.id, c.get('user').id, data));
1972
+ ctx.onPublish?.();
1973
+ await record(c, 'document.published', doc.title, { collection: def.name, id: doc.id, url: after });
1974
+ return c.json(toJson(doc));
1975
+ });
1976
+ app.post('/api/documents/:id/unpublish', requireUser, async (c) => {
1977
+ const existing = await loadDocument(c.req.param('id'));
1978
+ const doc = (await content.unpublish(existing.id, c.get('user').id));
1979
+ if (existing.publishedAt)
1980
+ ctx.onPublish?.();
1981
+ await record(c, 'document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
1982
+ return c.json(toJson(doc));
1983
+ });
1984
+ app.delete('/api/documents/:id', requireUser, requireAdmin, async (c) => {
1985
+ const existing = await loadDocument(c.req.param('id'));
1986
+ await content.remove(existing.id);
1987
+ if (existing.publishedAt)
1988
+ ctx.onPublish?.();
1989
+ await record(c, 'document.deleted', existing.title, { collection: existing.collection });
1990
+ return c.body(null, 204);
1991
+ });
1992
+ app.get('/api/documents/:id/revisions', requireUser, async (c) => {
1993
+ const existing = await loadDocument(c.req.param('id'));
1994
+ return c.json({ items: await content.revisions(existing.id) });
1995
+ });
1996
+ app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
1997
+ const existing = await loadDocument(c.req.param('id'));
1998
+ const doc = await content.restore(existing.id, c.req.param('revision'), c.get('user').id);
1999
+ if (!doc)
2000
+ throw new ApiError(404, 'not_found', 'This version does not exist');
2001
+ await record(c, 'document.restored', doc.title, { collection: doc.collection, id: doc.id });
2002
+ return c.json(toJson(doc));
2003
+ });
2004
+ // ---------- languages and translations ----------
2005
+ /** How many documents each language has: removing a language hides them from the site. */
2006
+ const countsByLocale = async () => {
2007
+ const out = {};
2008
+ for (const def of ctx.config().collections)
2009
+ for (const lang of new Set([...ctx.config().locales.available, ...base.config().locales.available]))
2010
+ out[lang] = (out[lang] ?? 0) + (await content.list(def.name, { locale: lang, limit: 1 })).total;
2011
+ return out;
2012
+ };
2013
+ app.get('/api/locales', requireUser, async (c) => {
2014
+ await languagesLoaded;
2015
+ return c.json({ locales: ctx.config().locales, documents: await countsByLocale() });
2016
+ });
2017
+ app.put('/api/locales', requireUser, requireSession, requireAdmin, async (c) => {
2018
+ const input = await body(c, z.object({
2019
+ default: z.string().refine(isLocaleCode, 'Codice lingua non valido'),
2020
+ available: z.array(z.string().refine(isLocaleCode, 'Codice lingua non valido')).min(1).max(30),
2021
+ }));
2022
+ if (!input.available.includes(input.default))
2023
+ throw new ApiError(400, 'invalid_locales', 'La lingua principale deve essere tra quelle del sito');
2024
+ const value = { default: input.default, available: [...new Set(input.available)] };
2025
+ await settings.set('locales', value, c.get('user').id);
2026
+ languages = value;
2027
+ ctx.onPublish?.();
2028
+ await record(c, 'settings.languages', value.available.join(', '));
2029
+ return c.json({ locales: ctx.config().locales, documents: await countsByLocale() });
2030
+ });
2031
+ app.get('/api/documents/:id/translations', requireUser, async (c) => {
2032
+ await languagesLoaded;
2033
+ const doc = await loadDocument(c.req.param('id'));
2034
+ const translations = await content.translations(doc.id);
2035
+ const have = new Set(translations.map((t) => t.locale));
2036
+ return c.json({
2037
+ translations,
2038
+ missing: ctx.config().locales.available.filter((l) => !have.has(l)),
2039
+ });
2040
+ });
2041
+ /** The texts of a document in another language, through the AI: the copy is changed in place. */
2042
+ const translateData = async (def, data, from, to, only) => {
2043
+ const ai = await readyAi();
2044
+ let slots = documentSlots(def.fields, data);
2045
+ if (only)
2046
+ slots = only(slots);
2047
+ if (!slots.length)
2048
+ return;
2049
+ const translated = await translateTexts(ai, slots.map((s) => s.text), from, to).catch(aiFailed);
2050
+ for (const [i, slot] of slots.entries())
2051
+ slot.set(translated[i]);
2052
+ };
2053
+ app.post('/api/documents/:id/translations', requireUser, async (c) => {
2054
+ const input = await body(c, z.object({ locale: z.string(), translate: z.boolean().default(false) }));
2055
+ await languagesLoaded;
2056
+ const doc = await loadDocument(c.req.param('id'));
2057
+ const def = collection(doc.collection);
2058
+ const lang = locale(input.locale);
2059
+ const group = await content.translations(doc.id);
2060
+ if (group.some((t) => t.locale === lang))
2061
+ throw new ApiError(409, 'translation_exists', 'Questa lingua esiste già: aprila dall’elenco delle lingue');
2062
+ if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0)
2063
+ throw new ApiError(409, 'singleton_exists', `"${def.label}" esiste già in questa lingua`);
2064
+ if (input.translate && !allowAi(c.get('user').id))
2065
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2066
+ const data = structuredClone(doc.data);
2067
+ if (input.translate) {
2068
+ // The address is translated too ("chi-siamo" → "about-us"), the home page keeps its own.
2069
+ const slugField = def.slugField;
2070
+ const slug = slugField && typeof data[slugField] === 'string' ? data[slugField] : '';
2071
+ const home = !slug || def.url?.({ ...data }) === '/';
2072
+ await translateData(def, data, doc.locale, lang);
2073
+ if (slugField && !home) {
2074
+ const [title] = await translateTexts(await readyAi(), [slug.replace(/-/g, ' ')], doc.locale, lang).catch(aiFailed);
2075
+ const next = slugify(title ?? '');
2076
+ if (next)
2077
+ data[slugField] = next;
2078
+ }
2079
+ }
2080
+ const draft = draftData(def, data);
2081
+ const original = group.find((t) => !t.sourceId) ?? group[0];
2082
+ const created = await slugGuard(() => content.create({
2083
+ collection: def.name,
2084
+ locale: lang,
2085
+ ...draft,
2086
+ userId: c.get('user').id,
2087
+ sourceId: original?.id ?? doc.id,
2088
+ }));
2089
+ await record(c, 'document.translated', created.title, { locale: lang, id: created.id, ai: input.translate });
2090
+ return c.json(toJson(created), 201);
2091
+ });
2092
+ /**
2093
+ * Brings a translation in line with its original: the original's structure and design, with the
2094
+ * words already translated; new elements come in the original's words, or translated by the AI.
2095
+ */
2096
+ app.post('/api/documents/:id/sync', requireUser, async (c) => {
2097
+ const input = await body(c, z.object({ translate: z.boolean().default(false) }));
2098
+ const doc = await loadDocument(c.req.param('id'));
2099
+ if (!doc.sourceId)
2100
+ throw new ApiError(400, 'not_a_translation', 'Questa è la versione originale');
2101
+ const original = await loadDocument(doc.sourceId);
2102
+ const def = collection(doc.collection);
2103
+ const data = structuredClone(doc.data);
2104
+ const fresh = new Set();
2105
+ for (const [name, f] of Object.entries(def.fields)) {
2106
+ if (f.kind !== 'page')
2107
+ continue;
2108
+ const from = Array.isArray(original.data[name]) ? original.data[name] : [];
2109
+ const mine = Array.isArray(data[name]) ? data[name] : [];
2110
+ for (const id of untranslatedIds(from, mine))
2111
+ fresh.add(id);
2112
+ data[name] = mergeTranslation(from, mine);
2113
+ }
2114
+ if (input.translate && fresh.size) {
2115
+ if (!allowAi(c.get('user').id))
2116
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2117
+ const ai = await readyAi();
2118
+ for (const [name, f] of Object.entries(def.fields)) {
2119
+ if (f.kind !== 'page' || !Array.isArray(data[name]))
2120
+ continue;
2121
+ const walk = (list) => list.flatMap((n) => (fresh.has(n.id) ? [n] : walk(n.children ?? [])));
2122
+ // Only the new elements' own texts: children of a new element are new too.
2123
+ const own = nodeSlots(walk(data[name]));
2124
+ if (!own.length)
2125
+ continue;
2126
+ const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale).catch(aiFailed);
2127
+ for (const [i, slot] of own.entries())
2128
+ slot.set(translated[i]);
2129
+ }
2130
+ }
2131
+ for (const [name, f] of Object.entries(def.fields))
2132
+ if (f.kind === 'page')
2133
+ await guardDesign(c.get('user'), doc.data[name], data[name]);
2134
+ const draft = draftData(def, data);
2135
+ const saved = await slugGuard(() => content.update(doc.id, { ...draft, userId: c.get('user').id }));
2136
+ return c.json({ document: toJson(saved), added: fresh.size });
2137
+ });
2138
+ // ---------- importing a WordPress site ----------
2139
+ /** Some hosts turn away requests that do not say who they are. */
2140
+ const IMPORT_AGENT = `Mozilla/5.0 (compatible; Materialize/${VERSION}; WordPress import)`;
2141
+ /** Sites read and waiting for their import plan, by token, for half an hour. */
2142
+ const wpSites = new Map();
2143
+ const wpJobs = new Map();
2144
+ const forget = () => {
2145
+ const now = Date.now();
2146
+ for (const [k, v] of wpSites)
2147
+ if (now - v.at > 30 * 60_000)
2148
+ wpSites.delete(k);
2149
+ for (const [k, v] of wpJobs)
2150
+ if (now - v.at > 6 * 60 * 60_000)
2151
+ wpJobs.delete(k);
2152
+ };
2153
+ /** A WordPress site is read from its address (REST API) or from its export file. */
2154
+ app.post('/api/import/wordpress/analyze', requireUser, requireSession, requireAdmin, async (c) => {
2155
+ forget();
2156
+ const isForm = (c.req.header('content-type') ?? '').includes('multipart/form-data');
2157
+ let site;
2158
+ try {
2159
+ if (isForm) {
2160
+ const form = await c.req.parseBody();
2161
+ const file = form.file;
2162
+ if (!(file instanceof File))
2163
+ throw new ApiError(400, 'missing_file', 'Send the export file in the "file" field');
2164
+ site = readWxr(await file.text());
2165
+ }
2166
+ else {
2167
+ const input = await body(c, z.object({
2168
+ url: z.url(),
2169
+ user: z.string().trim().max(200).optional(),
2170
+ password: z.string().trim().max(200).optional(),
2171
+ limit: z.number().int().min(1).max(10_000).optional(),
2172
+ }));
2173
+ // An application password (WordPress → Utenti → Profilo): drafts and private pages too.
2174
+ const headers = { accept: 'application/json', 'user-agent': IMPORT_AGENT };
2175
+ if (input.user && input.password)
2176
+ headers.authorization = `Basic ${Buffer.from(`${input.user}:${input.password.replace(/\s+/g, '')}`).toString('base64')}`;
2177
+ const fetcher = async (url) => {
2178
+ const res = await remoteFetch(url, { max: 50 * 1024 * 1024, headers, allowPrivate: true, timeout: 30_000 });
2179
+ return { status: res.status, headers: res.headers, text: res.bytes.toString('utf8') };
2180
+ };
2181
+ site = await readRest(input.url, fetcher, { limit: input.limit });
2182
+ }
2183
+ }
2184
+ catch (error) {
2185
+ if (error instanceof WordPressError)
2186
+ throw new ApiError(422, 'wordpress_unreadable', error.message);
2187
+ if (error instanceof RemoteError)
2188
+ throw new ApiError(422, 'wordpress_unreadable', `Il sito non risponde: ${error.message}`);
2189
+ throw error;
2190
+ }
2191
+ const token = randomBytes(16).toString('hex');
2192
+ wpSites.set(token, { site, at: Date.now() });
2193
+ const collections = ctx.config().collections;
2194
+ const count = (kind) => site.items.filter((i) => i.kind === kind);
2195
+ return c.json({
2196
+ token,
2197
+ site: { name: site.name, url: site.url },
2198
+ counts: {
2199
+ posts: count('post').length,
2200
+ pages: count('page').length,
2201
+ drafts: site.items.filter((i) => i.status !== 'publish').length,
2202
+ },
2203
+ samples: {
2204
+ posts: count('post')
2205
+ .slice(0, 5)
2206
+ .map((i) => i.title),
2207
+ pages: count('page')
2208
+ .slice(0, 5)
2209
+ .map((i) => i.title),
2210
+ },
2211
+ categories: [...new Set(site.items.flatMap((i) => i.categories))].sort(),
2212
+ suggestions: { posts: suggest(collections, 'post'), pages: suggest(collections, 'page') },
2213
+ // For each collection, which sources each field can take, and the choices of choice fields.
2214
+ fields: Object.fromEntries(collections
2215
+ .filter((def) => !def.singleton)
2216
+ .map((def) => [
2217
+ def.name,
2218
+ Object.fromEntries(Object.entries(def.fields)
2219
+ .filter(([, f]) => sourcesFor(f).length)
2220
+ .map(([name, f]) => [
2221
+ name,
2222
+ {
2223
+ label: f.label ?? name,
2224
+ kind: f.kind,
2225
+ sources: sourcesFor(f),
2226
+ options: f.kind === 'select' ? optionsOf(f) : undefined,
2227
+ },
2228
+ ])),
2229
+ ])),
2230
+ });
2231
+ });
2232
+ app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
2233
+ const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
2234
+ const input = await body(c, z.object({
2235
+ token: z.string(),
2236
+ plan: z.object({
2237
+ posts: target,
2238
+ pages: target,
2239
+ publish: z.boolean(),
2240
+ redirects: z.boolean(),
2241
+ locale: z.string().optional(),
2242
+ categories: z.record(z.string(), z.string()).optional(),
2243
+ fallbackCategory: z.string().optional(),
2244
+ }),
2245
+ }));
2246
+ const held = wpSites.get(input.token);
2247
+ if (!held)
2248
+ throw new ApiError(409, 'import_expired', 'Read the site again');
2249
+ const lang = locale(input.plan.locale);
2250
+ const plan = { ...input.plan, locale: lang };
2251
+ for (const t of [plan.posts, plan.pages])
2252
+ if (t)
2253
+ collection(t.collection);
2254
+ const user = c.get('user');
2255
+ const id = randomBytes(8).toString('hex');
2256
+ const job = {
2257
+ phase: 'media',
2258
+ done: 0,
2259
+ total: 0,
2260
+ created: [],
2261
+ skipped: [],
2262
+ problems: [],
2263
+ media: { downloaded: 0, failed: 0, reasons: {} },
2264
+ redirects: 0,
2265
+ at: Date.now(),
2266
+ };
2267
+ wpJobs.set(id, job);
2268
+ const known = (await settings.get('imports:media')) ?? {};
2269
+ const deps = {
2270
+ collections: ctx.config().collections,
2271
+ saveImage: async (url, alt) => {
2272
+ if (known[url])
2273
+ return { ...known[url], alt: alt || known[url].alt };
2274
+ let res;
2275
+ try {
2276
+ res = await remoteFetch(url, {
2277
+ max: MAX_UPLOAD,
2278
+ allowPrivate: true,
2279
+ timeout: 30_000,
2280
+ headers: { 'user-agent': IMPORT_AGENT },
2281
+ });
2282
+ }
2283
+ catch (error) {
2284
+ return {
2285
+ error: error instanceof RemoteError && /too large/.test(error.message)
2286
+ ? 'troppo grandi (oltre 25 MB)'
2287
+ : 'non raggiungibili',
2288
+ };
2289
+ }
2290
+ if (res.status !== 200)
2291
+ return { error: `non trovate sul sito (${res.status})` };
2292
+ if (/svg/.test(res.type) || /\.svg$/i.test(res.name))
2293
+ return { error: 'SVG: la libreria non li accetta, perché possono contenere codice' };
2294
+ try {
2295
+ const saved = await files.save(res.name, res.bytes);
2296
+ const asset = await mediaStore.create({ ...saved, filename: res.name.slice(0, 200), alt, createdBy: user.id });
2297
+ const media = {
2298
+ src: mediaUrl(asset),
2299
+ alt,
2300
+ ...(asset.width ? { width: asset.width } : {}),
2301
+ ...(asset.height ? { height: asset.height } : {}),
2302
+ };
2303
+ known[url] = media;
2304
+ return media;
2305
+ }
2306
+ catch {
2307
+ return { error: 'in un formato che la libreria non legge' };
2308
+ }
2309
+ },
2310
+ fetchPage: async (url) => {
2311
+ try {
2312
+ const res = await remoteFetch(url, {
2313
+ max: 20 * 1024 * 1024,
2314
+ allowPrivate: true,
2315
+ timeout: 30_000,
2316
+ headers: { 'user-agent': IMPORT_AGENT, accept: 'text/html' },
2317
+ });
2318
+ return res.status === 200 ? res.bytes.toString('utf8') : null;
2319
+ }
2320
+ catch {
2321
+ return null;
2322
+ }
2323
+ },
2324
+ exists: async (name, slug) => !!(await content.getBySlug(name, slug, lang)),
2325
+ titles: async (name) => {
2326
+ const { items } = await content.list(name, { locale: lang, limit: 200 });
2327
+ return new Map(items.map((d) => [fold(d.title), d.id]));
2328
+ },
2329
+ create: async (def, data) => {
2330
+ const draft = draftData(def, data);
2331
+ const doc = await slugGuard(() => content.create({ collection: def.name, locale: lang, ...draft, userId: user.id }));
2332
+ return { id: doc.id, title: doc.title, url: publicUrl(def, lang, doc.data) };
2333
+ },
2334
+ publish: async (docId) => {
2335
+ const doc = await loadDocument(docId);
2336
+ await content.publish(doc.id, user.id, publishableData(collection(doc.collection), doc.data));
2337
+ },
2338
+ urlOf: (def, data) => {
2339
+ try {
2340
+ return publicUrl(def, lang, data);
2341
+ }
2342
+ catch {
2343
+ return null;
2344
+ }
2345
+ },
2346
+ addRedirects: async (moves) => {
2347
+ let list = (await settings.get('redirects')) ?? [];
2348
+ const before = list.length;
2349
+ for (const m of moves)
2350
+ list = addMove(list, m.from, m.to);
2351
+ await settings.set('redirects', list, user.id);
2352
+ return list.length - before;
2353
+ },
2354
+ onProgress: (p) => Object.assign(job, p),
2355
+ };
2356
+ void runImport(held.site, plan, deps)
2357
+ .then(async (result) => {
2358
+ await settings.set('imports:media', known, user.id);
2359
+ if (result.created.some((d) => d.published) || result.redirects)
2360
+ ctx.onPublish?.();
2361
+ await activityLog.record({
2362
+ userId: user.id,
2363
+ userName: user.name,
2364
+ action: 'import.wordpress',
2365
+ target: held.site.name,
2366
+ detail: { created: result.created.length, media: result.media.downloaded, redirects: result.redirects },
2367
+ ip: null,
2368
+ });
2369
+ })
2370
+ .catch((error) => {
2371
+ console.error('Materialize: import failed', error);
2372
+ Object.assign(job, { phase: 'failed', error: error.message });
2373
+ });
2374
+ return c.json({ job: id }, 202);
2375
+ });
2376
+ app.get('/api/import/jobs/:id', requireUser, requireAdmin, (c) => {
2377
+ const job = wpJobs.get(c.req.param('id'));
2378
+ if (!job)
2379
+ throw new ApiError(404, 'not_found', 'There is no such import');
2380
+ const { at: _, ...progress } = job;
2381
+ return c.json(progress);
2382
+ });
2383
+ // ---------- media ----------
2384
+ const assetJson = (asset) => ({ ...asset, url: mediaUrl(asset) });
2385
+ const loadAsset = async (id) => {
2386
+ const asset = await mediaStore.get(id);
2387
+ if (!asset)
2388
+ throw new ApiError(404, 'not_found', 'This file does not exist');
2389
+ return asset;
2390
+ };
2391
+ app.get('/api/media', requireUser, async (c) => {
2392
+ const result = await mediaStore.list({
2393
+ search: c.req.query('search') || undefined,
2394
+ limit: Number(c.req.query('limit') ?? 60),
2395
+ offset: Number(c.req.query('offset') ?? 0),
2396
+ });
2397
+ return c.json({ total: result.total, items: result.items.map(assetJson) });
2398
+ });
2399
+ app.post('/api/media', requireUser, bodyLimit({
2400
+ maxSize: MAX_UPLOAD + 64 * 1024,
2401
+ onError: () => {
2402
+ throw new ApiError(413, 'file_too_large', `Files can be at most ${MAX_UPLOAD / 1024 / 1024} MB`);
2403
+ },
2404
+ }), async (c) => {
2405
+ const body = await c.req.parseBody();
2406
+ const file = body.file;
2407
+ if (!(file instanceof File))
2408
+ throw new ApiError(400, 'missing_file', 'Send the file in the "file" field');
2409
+ let saved;
2410
+ try {
2411
+ saved = await files.save(file.name, Buffer.from(await file.arrayBuffer()));
2412
+ }
2413
+ catch (error) {
2414
+ if (error instanceof MediaError)
2415
+ throw new ApiError(422, 'invalid_file', error.message);
2416
+ throw error;
2417
+ }
2418
+ const asset = await mediaStore.create({
2419
+ ...saved,
2420
+ filename: file.name.slice(0, 200),
2421
+ alt: typeof body.alt === 'string' ? body.alt : '',
2422
+ createdBy: c.get('user').id,
2423
+ });
2424
+ await record(c, 'media.uploaded', asset.filename);
2425
+ return c.json(assetJson(asset), 201);
2426
+ });
2427
+ // An image from the web, for AI tools: downloaded by the server, checked like an upload.
2428
+ app.post('/api/media/from-url', requireUser, async (c) => {
2429
+ const input = await body(c, z.object({ url: z.url(), alt: z.string().max(500).optional() }));
2430
+ let saved;
2431
+ let name;
2432
+ try {
2433
+ const remote = await fetchPublicFile(input.url, MAX_UPLOAD);
2434
+ name = remote.name;
2435
+ saved = await files.save(name, remote.bytes);
2436
+ }
2437
+ catch (error) {
2438
+ if (error instanceof RemoteError || error instanceof MediaError)
2439
+ throw new ApiError(422, 'invalid_file', error.message);
2440
+ throw error;
2441
+ }
2442
+ const asset = await mediaStore.create({
2443
+ ...saved,
2444
+ filename: name.slice(0, 200),
2445
+ alt: input.alt ?? '',
2446
+ createdBy: c.get('user').id,
2447
+ });
2448
+ await record(c, 'media.uploaded', asset.filename, { from: input.url });
2449
+ return c.json(assetJson(asset), 201);
2450
+ });
2451
+ app.get('/api/media/:id', requireUser, async (c) => {
2452
+ const asset = await loadAsset(c.req.param('id'));
2453
+ return c.json({ ...assetJson(asset), usage: await mediaStore.usage(asset.key) });
2454
+ });
2455
+ app.patch('/api/media/:id', requireUser, async (c) => {
2456
+ const asset = await loadAsset(c.req.param('id'));
2457
+ const input = await body(c, z.object({ alt: z.string().max(500) }));
2458
+ return c.json(assetJson((await mediaStore.update(asset.id, input))));
2459
+ });
2460
+ app.delete('/api/media/:id', requireUser, requireAdmin, async (c) => {
2461
+ const asset = await loadAsset(c.req.param('id'));
2462
+ await mediaStore.remove(asset.id);
2463
+ await files.remove(asset.key);
2464
+ await record(c, 'media.deleted', asset.filename);
2465
+ return c.body(null, 204);
2466
+ });
2467
+ // Public files: /media/<key> is the original, /media/<key>@<width>.<format> a resized copy.
2468
+ // Flat names, so a static export can hold both as plain files.
2469
+ const sendFile = async (c, key, variant) => {
2470
+ const file = await files.open(key, variant);
2471
+ if (!file)
2472
+ return c.json({ error: { code: 'not_found', message: 'Not found' } }, 404);
2473
+ return c.body(fileBody(file.path), 200, {
2474
+ 'content-type': file.type,
2475
+ 'cache-control': 'public, max-age=31536000, immutable',
2476
+ });
2477
+ };
2478
+ // ---------- fonts: Google Fonts served from this site ----------
2479
+ app.get('/api/fonts', requireUser, async (c) => c.json({ items: await fonts.list() }));
2480
+ app.get('/fonts/:file', async (c) => {
2481
+ const id = c.req.param('file').replace(/\.css$/, '');
2482
+ if (!c.req.param('file').endsWith('.css') || !FONT_ID.test(id))
2483
+ return c.text('Not found', 404);
2484
+ try {
2485
+ return c.body(await fonts.css(id), 200, {
2486
+ 'content-type': 'text/css; charset=utf-8',
2487
+ 'cache-control': 'public, max-age=86400',
2488
+ });
2489
+ }
2490
+ catch {
2491
+ return c.text('Font not available', 404);
2492
+ }
2493
+ });
2494
+ app.get('/fonts/:id/:file', async (c) => {
2495
+ const { id, file } = c.req.param();
2496
+ if (!FONT_ID.test(id) || !FONT_FILE.test(file))
2497
+ return c.text('Not found', 404);
2498
+ try {
2499
+ return c.body(new Uint8Array(await fonts.file(id, file)), 200, {
2500
+ 'content-type': 'font/woff2',
2501
+ 'cache-control': 'public, max-age=31536000, immutable',
2502
+ });
2503
+ }
2504
+ catch {
2505
+ return c.text('Font not available', 404);
2506
+ }
2507
+ });
2508
+ app.get('/media/:file', async (c) => {
2509
+ const file = c.req.param('file');
2510
+ const variant = /^(.+)@(\d{2,4})\.(avif|webp|jpg|png)$/.exec(file);
2511
+ if (!variant)
2512
+ return sendFile(c, file);
2513
+ return sendFile(c, variant[1], { width: Number(variant[2]), format: variant[3] });
2514
+ });
2515
+ // ---------- public delivery API: published content only ----------
2516
+ const publicDocument = (doc) => {
2517
+ const def = ctx.config().collections.find((x) => x.name === doc.collection);
2518
+ const data = doc.publishedData ?? {};
2519
+ return {
2520
+ id: doc.id,
2521
+ slug: doc.slug,
2522
+ locale: doc.locale,
2523
+ publishedAt: doc.publishedAt,
2524
+ url: def?.url ? def.url(data) : null,
2525
+ data,
2526
+ };
2527
+ };
2528
+ app.get('/api/content/:name', async (c) => {
2529
+ const def = collection(c.req.param('name'));
2530
+ const lang = locale(c.req.query('locale'));
2531
+ const list = await content.list(def.name, {
2532
+ locale: lang,
2533
+ publishedOnly: true,
2534
+ limit: Number(c.req.query('limit') ?? 50),
2535
+ offset: Number(c.req.query('offset') ?? 0),
2536
+ });
2537
+ const docs = await Promise.all(list.items.map((item) => content.get(item.id)));
2538
+ return c.json({ total: list.total, items: docs.filter((d) => !!d).map(publicDocument) });
2539
+ });
2540
+ app.get('/api/content/:name/:slug', async (c) => {
2541
+ const def = collection(c.req.param('name'));
2542
+ const doc = await content.getBySlug(def.name, c.req.param('slug'), locale(c.req.query('locale')));
2543
+ if (!doc?.publishedData)
2544
+ throw new ApiError(404, 'not_found', 'This page is not published');
2545
+ return c.json(publicDocument(doc));
2546
+ });
2547
+ return app;
2548
+ }