@materializecms/server 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/forms.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type FormDefinition } from '@materializecms/core/page';
2
+ import type { SubmissionFile } from '@materializecms/db';
3
+ /** Settings for every form, kept in the "forms" setting. */
4
+ export interface FormsSettings {
5
+ /** Messages older than this many months are deleted with their files; unset keeps them. */
6
+ keepMonths?: number;
7
+ /** Signs webhook calls, so the receiver can tell they come from this site. */
8
+ webhookSecret?: string;
9
+ }
10
+ export interface FieldIssue {
11
+ field: string;
12
+ message: string;
13
+ }
14
+ /** A sent file, before it is saved. */
15
+ export interface IncomingFile {
16
+ field: string;
17
+ name: string;
18
+ type: string;
19
+ bytes: Uint8Array;
20
+ }
21
+ /**
22
+ * What a form sent, checked against the form on the page: only its fields, the required ones
23
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
24
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
25
+ */
26
+ export declare function readSubmission(raw: Record<string, unknown>, def: FormDefinition | null): Promise<{
27
+ data: Record<string, string>;
28
+ files: IncomingFile[];
29
+ issues: FieldIssue[];
30
+ }>;
31
+ /**
32
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
33
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
34
+ */
35
+ export declare function spamReason(data: Record<string, string>, elapsedMs: number | null): string | null;
36
+ /** The same words from the same form: two identical messages in a day are a repeat. */
37
+ export declare const fingerprintOf: (form: string, data: Record<string, string>) => string;
38
+ /** Saves a message's files in its own folder, out of reach of the public site. */
39
+ export declare function saveFiles(dir: string, id: string, files: IncomingFile[]): Promise<SubmissionFile[]>;
40
+ export declare const removeFiles: (dir: string, id: string) => Promise<void>;
41
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
42
+ export declare const webhookSignature: (secret: string, body: string) => string;
43
+ /**
44
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
45
+ * longer each time. Resolves with whether it was taken.
46
+ */
47
+ export declare function sendWebhook(url: string, payload: unknown, secret: string, wait?: (ms: number) => Promise<void>): Promise<boolean>;
package/src/forms.js ADDED
@@ -0,0 +1,153 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { FILE_TYPES, FORM_FILE_MAX } from '@materializecms/core/page';
5
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+ const isFile = (v) => typeof v === 'object' && v !== null && 'arrayBuffer' in v && typeof v.name === 'string';
7
+ /**
8
+ * What a form sent, checked against the form on the page: only its fields, the required ones
9
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
10
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
11
+ */
12
+ export async function readSubmission(raw, def) {
13
+ const data = {};
14
+ const files = [];
15
+ const issues = [];
16
+ if (!def) {
17
+ for (const [k, v] of Object.entries(raw)) {
18
+ if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
19
+ continue;
20
+ data[k.slice(0, 80)] = v.slice(0, 5000);
21
+ }
22
+ return { data, files, issues };
23
+ }
24
+ for (const field of def.fields) {
25
+ const value = raw[field.name];
26
+ if (field.kind === 'file') {
27
+ const file = isFile(value) && value.size > 0 ? value : null;
28
+ if (!file) {
29
+ if (field.required)
30
+ issues.push({ field: field.name, message: 'Allega un file' });
31
+ continue;
32
+ }
33
+ const allowed = FILE_TYPES[field.accept ?? 'any'];
34
+ if (!allowed.includes(extname(file.name).toLowerCase())) {
35
+ issues.push({ field: field.name, message: `Questo tipo di file non è accettato (${allowed.join(', ')})` });
36
+ continue;
37
+ }
38
+ if (file.size > FORM_FILE_MAX) {
39
+ issues.push({ field: field.name, message: `Il file supera ${FORM_FILE_MAX / 1024 / 1024} MB` });
40
+ continue;
41
+ }
42
+ files.push({
43
+ field: field.name,
44
+ name: file.name,
45
+ type: file.type,
46
+ bytes: new Uint8Array(await file.arrayBuffer()),
47
+ });
48
+ data[field.name] = file.name;
49
+ continue;
50
+ }
51
+ const text = typeof value === 'string' ? value.trim().slice(0, 5000) : '';
52
+ if (field.kind === 'checkbox') {
53
+ if (text)
54
+ data[field.name] = 'sì';
55
+ else if (field.required)
56
+ issues.push({ field: field.name, message: 'Va spuntata per inviare' });
57
+ continue;
58
+ }
59
+ if (!text) {
60
+ if (field.required)
61
+ issues.push({ field: field.name, message: 'Campo obbligatorio' });
62
+ continue;
63
+ }
64
+ if (field.kind === 'email' && !EMAIL.test(text)) {
65
+ issues.push({ field: field.name, message: 'Scrivi un indirizzo email valido' });
66
+ continue;
67
+ }
68
+ if (field.kind === 'select' && field.options?.length && !field.options.includes(text)) {
69
+ issues.push({ field: field.name, message: 'Scegli una delle voci dell’elenco' });
70
+ continue;
71
+ }
72
+ data[field.name] = text;
73
+ }
74
+ return { data, files, issues };
75
+ }
76
+ /** A form filled in faster than this is a bot (people need a few seconds at least). */
77
+ const MIN_FILL_MS = 2500;
78
+ /**
79
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
80
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
81
+ */
82
+ export function spamReason(data, elapsedMs) {
83
+ if (elapsedMs !== null && elapsedMs >= 0 && elapsedMs < MIN_FILL_MS)
84
+ return 'Compilato in meno di 3 secondi';
85
+ const text = Object.values(data).join('\n');
86
+ if (/\[url=|<a\s+href|\[link=/i.test(text))
87
+ return 'Link nascosti nel testo';
88
+ const links = text.match(/https?:\/\/|www\./gi)?.length ?? 0;
89
+ if (links > 3)
90
+ return `Troppi link (${links})`;
91
+ return null;
92
+ }
93
+ /** The same words from the same form: two identical messages in a day are a repeat. */
94
+ export const fingerprintOf = (form, data) => createHash('sha256')
95
+ .update(form)
96
+ .update(JSON.stringify(Object.entries(data)
97
+ .sort(([a], [b]) => a.localeCompare(b))
98
+ .map(([k, v]) => [k, v.toLowerCase().replace(/\s+/g, ' ')])))
99
+ .digest('hex');
100
+ /** A file name safe on any disk, keeping its extension. */
101
+ const safeName = (name) => name
102
+ .normalize('NFKD')
103
+ .replace(/[^\w.-]+/g, '-')
104
+ .replace(/^-+|-+$/g, '')
105
+ .slice(-80) || 'file';
106
+ /** Saves a message's files in its own folder, out of reach of the public site. */
107
+ export async function saveFiles(dir, id, files) {
108
+ if (!files.length)
109
+ return [];
110
+ const folder = join(dir, id);
111
+ await mkdir(folder, { recursive: true });
112
+ const saved = [];
113
+ for (const [i, f] of files.entries()) {
114
+ const path = `${id}/${i + 1}-${safeName(f.name)}`;
115
+ await writeFile(join(dir, path), f.bytes);
116
+ saved.push({ field: f.field, name: f.name, size: f.bytes.length, type: f.type, path });
117
+ }
118
+ return saved;
119
+ }
120
+ export const removeFiles = (dir, id) => rm(join(dir, id), { recursive: true, force: true }).catch(() => { });
121
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
122
+ export const webhookSignature = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
123
+ /**
124
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
125
+ * longer each time. Resolves with whether it was taken.
126
+ */
127
+ export async function sendWebhook(url, payload, secret, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
128
+ const body = JSON.stringify(payload);
129
+ for (const delay of [0, 20_000, 120_000]) {
130
+ if (delay)
131
+ await wait(delay);
132
+ try {
133
+ const res = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ 'user-agent': 'Materialize-Webhook',
138
+ 'x-materialize-signature': webhookSignature(secret, body),
139
+ },
140
+ body,
141
+ redirect: 'error',
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+ if (res.ok)
145
+ return true;
146
+ // The receiver refused it on purpose: trying again would not help.
147
+ if (res.status >= 400 && res.status < 500 && res.status !== 429)
148
+ return false;
149
+ }
150
+ catch { }
151
+ }
152
+ return false;
153
+ }
package/src/tools.js CHANGED
@@ -257,7 +257,7 @@ export function assistantTools(call, page, brand) {
257
257
  },
258
258
  });
259
259
  return [
260
- ...contentTools(call, getSchema).filter((t) => t.name !== 'publish_entry' && t.name !== 'unpublish_entry'),
260
+ ...contentTools(call, getSchema).filter((t) => !['publish_entry', 'unpublish_entry', 'schedule_entry', 'share_preview'].includes(t.name)),
261
261
  ...pageTools({ source, withId: false, brand: async () => brand }),
262
262
  ];
263
263
  }
@@ -368,6 +368,38 @@ export function contentTools(call, getSchema) {
368
368
  return { id: doc.id, title: doc.title, published: false };
369
369
  },
370
370
  }),
371
+ tool({
372
+ name: 'schedule_entry',
373
+ title: 'Programmo',
374
+ description: 'Sets when an entry goes on the public site (publish) or comes off it (unpublish) by itself, as an ISO date with time zone, e.g. 2026-11-10T09:00:00+01:00. at null cancels. The draft as it is at that moment is what gets published.',
375
+ input: z.object({
376
+ id: z.string(),
377
+ action: z.enum(['publish', 'unpublish']),
378
+ at: z.string().nullable(),
379
+ }),
380
+ run: async (input) => {
381
+ const doc = await call('PUT', `/api/documents/${encodeURIComponent(input.id)}/schedule`, { action: input.action, at: input.at });
382
+ return {
383
+ id: doc.id,
384
+ title: doc.title,
385
+ publishAt: doc.schedule.publish?.runAt ?? null,
386
+ unpublishAt: doc.schedule.unpublish?.runAt ?? null,
387
+ };
388
+ },
389
+ }),
390
+ tool({
391
+ name: 'share_preview',
392
+ title: 'Creo un link di anteprima',
393
+ description: 'A secret link that shows an entry’s latest saved draft with the site’s design to someone without an account (a client, a colleague). It follows later edits. days: how long it works (default 7), null until revoked.',
394
+ input: z.object({
395
+ id: z.string(),
396
+ days: z.number().int().min(1).max(365).nullable().optional(),
397
+ }),
398
+ run: async (input) => {
399
+ const link = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/previews`, { days: input.days === undefined ? 7 : input.days });
400
+ return { url: link.url, expiresAt: link.expiresAt };
401
+ },
402
+ }),
371
403
  tool({
372
404
  name: 'list_translations',
373
405
  title: 'Guardo le lingue',