@materializecms/server 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/admin.js +3 -2
- package/src/ai.js +17 -16
- package/src/app.d.ts +5 -0
- package/src/app.js +518 -264
- package/src/brief.js +3 -2
- package/src/en/api.d.ts +2 -0
- package/src/en/api.js +199 -0
- package/src/en/index.d.ts +5 -0
- package/src/en/index.js +7 -0
- package/src/en/services.d.ts +2 -0
- package/src/en/services.js +154 -0
- package/src/forms.d.ts +6 -3
- package/src/forms.js +22 -11
- package/src/index.d.ts +13 -0
- package/src/index.js +52 -2
- package/src/lang.d.ts +7 -0
- package/src/lang.js +17 -0
- package/src/mail.d.ts +6 -2
- package/src/mail.js +73 -40
- package/src/media.js +3 -2
- package/src/offsite.js +12 -9
- package/src/remote-storage.js +24 -23
- package/src/remote.d.ts +25 -8
- package/src/remote.js +210 -45
- package/src/status-page.js +9 -4
- package/src/t.d.ts +2 -0
- package/src/t.js +4 -0
- package/src/tools.js +28 -27
- package/src/wordpress.js +12 -11
- package/src/wpimport.js +27 -23
package/src/brief.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeTheme } from '@materializecms/core/page';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { t } from './t.js';
|
|
3
4
|
import { contentTools, pageTools, ToolError } from './tools.js';
|
|
4
5
|
/**
|
|
5
6
|
* A site from a brief: the AI reads a few lines about the business and builds the first pages
|
|
@@ -100,7 +101,7 @@ export function briefTools(call, options) {
|
|
|
100
101
|
}),
|
|
101
102
|
{
|
|
102
103
|
name: 'propose_theme',
|
|
103
|
-
title: 'Propongo colori e caratteri',
|
|
104
|
+
title: t('Propongo colori e caratteri'),
|
|
104
105
|
description: 'Proposes the site’s colors (light and dark), fonts (Google Fonts names) and roundness. Nothing changes now: the person sees the proposal at the end and applies it if they like it. Call it once, with the whole palette; pages built meanwhile use the theme tokens, so they follow it.',
|
|
105
106
|
input: z.object({
|
|
106
107
|
display: z.string().max(60).optional().describe('Font for titles'),
|
|
@@ -130,7 +131,7 @@ export function briefTools(call, options) {
|
|
|
130
131
|
if (options.askVoice)
|
|
131
132
|
tools.push({
|
|
132
133
|
name: 'propose_voice',
|
|
133
|
-
title: 'Propongo la voce del brand',
|
|
134
|
+
title: t('Propongo la voce del brand'),
|
|
134
135
|
description: 'Proposes how the site speaks, from the brief: who the brand is and who it writes for, up to three tones, how readers are addressed, words to prefer and to avoid, other rules. Nothing is saved now: the person sees it at the end. Call it once.',
|
|
135
136
|
input: z.object({
|
|
136
137
|
about: z.string().max(1000).optional(),
|
package/src/en/api.d.ts
ADDED
package/src/en/api.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** The server's API (app.ts): errors and checks the panel shows, emails to the team. */
|
|
2
|
+
export const api = {
|
|
3
|
+
'Codice lingua non valido': 'Not a valid language code',
|
|
4
|
+
'Backup fuori dal server non riuscito': 'Off-server backup failed',
|
|
5
|
+
'La copia dei backup di {site} fuori dal server non è riuscita: {message}': 'The off-server copy of the {site} backups failed: {message}',
|
|
6
|
+
'Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.': 'Check Settings → Security → Backups. We will write again only if it starts working and then stops again.',
|
|
7
|
+
'Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli': 'Only an administrator can change custom code and where form messages go',
|
|
8
|
+
'Puoi modificare testi, collegamenti e immagini, non il layout della pagina': 'You can change texts, links and images, not the page layout',
|
|
9
|
+
'Versione adatta.': 'Supported version.',
|
|
10
|
+
'Serve Node 24 o più recente.': 'Node 24 or later is required.',
|
|
11
|
+
'Cartella dei file': 'Files folder',
|
|
12
|
+
'Database e immagini si possono salvare.': 'The database and images can be saved.',
|
|
13
|
+
'Il programma non può scrivere in {dir}: dagli i permessi su quella cartella.': 'The program cannot write to {dir}: give it permission on that folder.',
|
|
14
|
+
Sito: 'Site',
|
|
15
|
+
'Il sito si genera da solo a ogni pubblicazione.': 'The site builds itself every time you publish.',
|
|
16
|
+
'Solo il CMS: in questa cartella non c’è un sito Astro.': 'CMS only: there is no Astro site in this folder.',
|
|
17
|
+
'Connessione sicura': 'Secure connection',
|
|
18
|
+
'HTTPS attivo.': 'HTTPS is on.',
|
|
19
|
+
'In locale non serve HTTPS.': 'HTTPS is not needed locally.',
|
|
20
|
+
'Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).': 'Without HTTPS the password travels in plain text: turn it on before going online (guide in docs/sicurezza.md).',
|
|
21
|
+
Aggiornamenti: 'Updates',
|
|
22
|
+
'Si installano dal pannello, con un clic.': 'They install from the panel, in one click.',
|
|
23
|
+
'Si installano da terminale.': 'They install from the terminal.',
|
|
24
|
+
'Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice': 'To install, use the link printed when the server starts (materialize start), with its code',
|
|
25
|
+
'Troppi tentativi: riprova tra un minuto': 'Too many attempts: try again in a minute',
|
|
26
|
+
'Troppi tentativi: riprova tra {n} minuti': 'Too many attempts: try again in {n} minutes',
|
|
27
|
+
'Tentativi di accesso non riusciti': 'Failed sign-in attempts',
|
|
28
|
+
'Qualcuno ha provato {n} 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.': 'Someone tried {n} times in a row to sign in to the site’s panel with your address, without the right password. To be safe, sign-ins with your address are paused for a few minutes.',
|
|
29
|
+
'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.': 'If it was you, wait and try again. If it was not you, your password is still safe: for more protection, turn on two-step verification in your profile.',
|
|
30
|
+
'Nuova password': 'New password',
|
|
31
|
+
'Qualcuno ha chiesto una nuova password per il tuo account su {site}.': 'Someone asked for a new password for your account on {site}.',
|
|
32
|
+
'Se non sei stato tu, ignora questa email: la password resta quella di prima.': 'If it was not you, ignore this email: your password stays the same.',
|
|
33
|
+
'Per sceglierne una nuova, apri questo link entro un’ora:': 'To choose a new one, open this link within an hour:',
|
|
34
|
+
'La password non è corretta': 'The password is wrong',
|
|
35
|
+
'Il codice non è corretto': 'The code is wrong',
|
|
36
|
+
'Un amministratore entra con la sola password': 'One administrator signs in with a password only',
|
|
37
|
+
'{n} amministratori entrano con la sola password': '{n} administrators sign in with a password only',
|
|
38
|
+
'{names}: chi ruba quella password controlla tutto il sito. Attiva la verifica in due passaggi dal profilo → Sicurezza.': '{names}: whoever steals that password controls the whole site. Turn on two-step verification in your profile → Security.',
|
|
39
|
+
'Gli amministratori usano la verifica in due passaggi': 'Administrators use two-step verification',
|
|
40
|
+
'Per entrare servono password e codice dal telefono.': 'Signing in takes a password and a code from the phone.',
|
|
41
|
+
'Verifica in due passaggi richiesta a tutti': 'Two-step verification required for everyone',
|
|
42
|
+
'Una persona la attiverà al prossimo accesso.': 'One person will turn it on at their next sign-in.',
|
|
43
|
+
'{n} persone la attiveranno al prossimo accesso.': '{n} people will turn it on at their next sign-in.',
|
|
44
|
+
'Tutti l’hanno attivata.': 'Everyone has turned it on.',
|
|
45
|
+
'Una persona entra con la sola password': 'One person signs in with a password only',
|
|
46
|
+
'{n} persone entrano con la sola password': '{n} people sign in with a password only',
|
|
47
|
+
'Tutti usano la verifica in due passaggi': 'Everyone uses two-step verification',
|
|
48
|
+
'Puoi richiederla a tutti da Persone: chi non l’ha la attiverà al prossimo accesso.': 'You can require it for everyone in People: whoever does not have it will turn it on at their next sign-in.',
|
|
49
|
+
'Puoi anche richiederla a tutti da Persone, per chi entrerà in futuro.': 'You can also require it for everyone in People, for whoever joins later.',
|
|
50
|
+
'Gli avvisi arrivano per email': 'Alerts arrive by email',
|
|
51
|
+
'Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.': 'Failed sign-ins and changes of address or verification are reported to the people they concern.',
|
|
52
|
+
'L’invio delle email non è configurato': 'Email is not set up',
|
|
53
|
+
'Nessuno viene avvisato di tentativi di accesso falliti o di un indirizzo cambiato, e i link per una nuova password vanno mandati a mano.': 'Nobody is told about failed sign-ins or a changed address, and links for a new password must be sent by hand.',
|
|
54
|
+
'Pannello aperto in locale': 'Panel open locally',
|
|
55
|
+
'Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.': 'Online, the site must be served over HTTPS: passwords and sessions travel encrypted.',
|
|
56
|
+
'Connessione cifrata (HTTPS)': 'Encrypted connection (HTTPS)',
|
|
57
|
+
'Password e sessioni viaggiano cifrate.': 'Passwords and sessions travel encrypted.',
|
|
58
|
+
'Il pannello non usa HTTPS': 'The panel does not use HTTPS',
|
|
59
|
+
'Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.': 'Passwords and sessions travel in plain text: turn on HTTPS at your host (Let’s Encrypt is free) or put the site behind Cloudflare.',
|
|
60
|
+
'Il sito è dietro un proxy, ma non lo sa': 'The site is behind a proxy, but does not know it',
|
|
61
|
+
'Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: {code} in materialize.config.ts.': 'All requests seem to come from the same address, so sign-in limits per address do not work. Set server: {code} in materialize.config.ts.',
|
|
62
|
+
'trustProxy è acceso, ma non arriva nessun proxy': 'trustProxy is on, but no proxy is in front',
|
|
63
|
+
'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.': 'Without a proxy in front, anyone can claim a false address and get around sign-in limits. If the site is not behind a proxy, turn it off in materialize.config.ts.',
|
|
64
|
+
'Un accesso fallito nelle ultime 24 ore': 'One failed sign-in in the last 24 hours',
|
|
65
|
+
'{n} accessi falliti nelle ultime 24 ore': '{n} failed sign-ins in the last 24 hours',
|
|
66
|
+
'Nessun accesso fallito nelle ultime 24 ore': 'No failed sign-ins in the last 24 hours',
|
|
67
|
+
'In pausa adesso: {list}. Le pause finiscono da sole.': 'Paused now: {list}. Pauses end by themselves.',
|
|
68
|
+
'indirizzo {ip}': 'address {ip}',
|
|
69
|
+
'un codice di verifica': 'a verification code',
|
|
70
|
+
'Dopo 5 errori un’email va in pausa, dopo 20 un indirizzo: la pausa raddoppia a ogni nuovo errore.': 'After 5 failures an email is paused, after 20 an address: the pause doubles with each new failure.',
|
|
71
|
+
'Database ospitato da un fornitore': 'Database hosted by a provider',
|
|
72
|
+
'I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.': 'The provider (Turso, libSQL) backs up the database: check that backups are on in its dashboard.',
|
|
73
|
+
'Backup automatici spenti': 'Automatic backups are off',
|
|
74
|
+
'L’ultima copia è di un giorno fa. Riaccendili qui sotto.': 'The last copy is a day old. Turn them back on below.',
|
|
75
|
+
'L’ultima copia è di {n} giorni fa. Riaccendili qui sotto.': 'The last copy is {n} days old. Turn them back on below.',
|
|
76
|
+
'Non c’è nessuna copia del database. Riaccendili qui sotto.': 'There is no copy of the database. Turn them back on below.',
|
|
77
|
+
'L’ultimo backup è vecchio': 'The last backup is old',
|
|
78
|
+
'Il primo backup arriva a breve': 'The first backup is coming soon',
|
|
79
|
+
'Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.': 'The server makes one copy a day while it is running: if it stays off for long, download one by hand.',
|
|
80
|
+
'Il server fa una copia del database poco dopo l’avvio, poi una al giorno.': 'The server copies the database soon after it starts, then once a day.',
|
|
81
|
+
'Backup del database ogni giorno': 'Daily database backup',
|
|
82
|
+
'Ultima copia {when}, immagini comprese; ne restano {keep}.': 'Last copy {when}, images included; {keep} are kept.',
|
|
83
|
+
'I backup restano sul server': 'Backups stay on the server',
|
|
84
|
+
'Se il server si rompe, si perdono anche le copie. Attiva la copia fuori dal server (S3 o WebDAV), oppure scarica ogni tanto l’archivio completo.': 'If the server breaks, the copies are lost too. Turn on the off-server copy (S3 or WebDAV), or download the full archive from time to time.',
|
|
85
|
+
'La copia fuori dal server non riesce': 'The off-server copy is failing',
|
|
86
|
+
'Controlla il collegamento.': 'Check the connection.',
|
|
87
|
+
'Copia cifrata fuori dal server': 'Encrypted off-server copy',
|
|
88
|
+
'Ultima copia {when}, su {where}.': 'Last copy {when}, on {where}.',
|
|
89
|
+
'La prima copia parte entro un’ora.': 'The first copy starts within an hour.',
|
|
90
|
+
'Un token di accesso per i client AI': 'One access token for AI clients',
|
|
91
|
+
'{n} token di accesso per i client AI': '{n} access tokens for AI clients',
|
|
92
|
+
'Uno non è usato da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.': 'One has not been used in over 90 days: whoever created it can revoke it in Settings → AI and MCP.',
|
|
93
|
+
'{n} non sono usati da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.': '{n} have not been used in over 90 days: whoever created them can revoke them in Settings → AI and MCP.',
|
|
94
|
+
'Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.': 'All used recently. Everyone can revoke their own in Settings → AI and MCP.',
|
|
95
|
+
'Questo server non è avviato da materialize start: aggiorna da terminale.': 'This server was not started with materialize start: update from the terminal.',
|
|
96
|
+
'Gli aggiornamenti dal pannello non sono disponibili qui': 'Updates from the panel are not available here',
|
|
97
|
+
'Il tuo indirizzo email è cambiato': 'Your email address has changed',
|
|
98
|
+
'Hai cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.': 'You changed the address you use to sign in to the site’s panel: from {before} to {after}.',
|
|
99
|
+
'{by} ha cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.': '{by} changed the address you use to sign in to the site’s panel: from {before} to {after}.',
|
|
100
|
+
'Se non te lo aspettavi, rispondi a questa email o avvisa un amministratore del sito.': 'If you did not expect this, reply to this email or tell an administrator of the site.',
|
|
101
|
+
'La password attuale non è corretta': 'The current password is wrong',
|
|
102
|
+
'Il tuo invito': 'Your invitation',
|
|
103
|
+
'{name} ti ha invitato a lavorare sul sito con Materialize.': '{name} invited you to work on the site with Materialize.',
|
|
104
|
+
'Apri questo link per scegliere la tua password ed entrare:': 'Open this link to choose your password and sign in:',
|
|
105
|
+
'Verifica in due passaggi disattivata': 'Two-step verification turned off',
|
|
106
|
+
'{name} ha disattivato la verifica in due passaggi del tuo account: per entrare nel pannello ora basta la password.': '{name} turned off two-step verification on your account: signing in to the panel now takes only the password.',
|
|
107
|
+
'Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.': 'Turn it back on in your profile as soon as you can. If you did not expect this, tell an administrator.',
|
|
108
|
+
'È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:': 'A new password was requested for your account. The link is valid for 24 hours:',
|
|
109
|
+
'La sezione è ancora in {pages}: toglila prima dalle pagine': 'The section is still in {pages}: remove it from those pages first',
|
|
110
|
+
'{name} sta modificando questo documento: le tue modifiche non sono state salvate': '{name} is editing this document: your changes were not saved',
|
|
111
|
+
'Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate': 'This document is open in another tab: the changes made here were not saved',
|
|
112
|
+
'Prova di invio da Materialize': 'Test email from Materialize',
|
|
113
|
+
'Se leggi questo messaggio, l’invio delle email dal sito funziona.': 'If you are reading this, the site can send email.',
|
|
114
|
+
'L’AI non è configurata: Impostazioni → AI e MCP': 'AI is not set up: Settings → AI and MCP',
|
|
115
|
+
'Troppe richieste: aspetta un minuto': 'Too many requests: wait a minute',
|
|
116
|
+
'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano': 'The site has too little published text yet: write the voice by hand',
|
|
117
|
+
'L’AI non ha risposto come doveva: riprova': 'The AI did not reply as expected: try again',
|
|
118
|
+
'Non c’è testo da rivedere': 'There is no text to review',
|
|
119
|
+
'Quel testo è cambiato: rifai la revisione': 'That text has changed: run the review again',
|
|
120
|
+
'Scegli prima un’immagine della libreria': 'Choose an image from the library first',
|
|
121
|
+
'Non c’è testo su cui lavorare': 'There is no text to work on',
|
|
122
|
+
'Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.': 'You can change only the contents: the layout changes were not applied.',
|
|
123
|
+
Errore: 'Error',
|
|
124
|
+
'Scrivi qualche riga in più sull’attività': 'Write a few more lines about the business',
|
|
125
|
+
'Scrivi il brief': 'Write the brief',
|
|
126
|
+
'Questa conversazione non c’è più: parti da un nuovo brief': 'This conversation is gone: start from a new brief',
|
|
127
|
+
'Il sito non ha una raccolta di pagine costruite sulla tela': 'The site has no collection of pages built on the canvas',
|
|
128
|
+
'Messaggio ripetuto': 'Repeated message',
|
|
129
|
+
'Allegati ({files}): li trovi in Impostazioni → Messaggi.': 'Attachments ({files}): find them in Settings → Messages.',
|
|
130
|
+
'Nuovo messaggio dal sito: {subject}': 'New message from the site: {subject}',
|
|
131
|
+
'Nuovo messaggio dal sito': 'New message from the site',
|
|
132
|
+
Data: 'Date',
|
|
133
|
+
Modulo: 'Form',
|
|
134
|
+
Pagina: 'Page',
|
|
135
|
+
'Segnato a mano': 'Marked by hand',
|
|
136
|
+
'File non trovato': 'File not found',
|
|
137
|
+
'Il file non c’è più': 'The file is gone',
|
|
138
|
+
'Un ID di Google Analytics 4 inizia con G-': 'A Google Analytics 4 ID starts with G-',
|
|
139
|
+
'Un ID di Tag Manager inizia con GTM-': 'A Tag Manager ID starts with GTM-',
|
|
140
|
+
'L’ID del Pixel è fatto di sole cifre': 'The Pixel ID is made of digits only',
|
|
141
|
+
'Scrivi il dominio come su Plausible, per esempio example.com': 'Write the domain as on Plausible, for example example.com',
|
|
142
|
+
'Un indirizzo https://': 'An https:// address',
|
|
143
|
+
'L’indirizzo di Matomo, con https://': 'The Matomo address, with https://',
|
|
144
|
+
'Il numero del sito su Matomo': 'The site’s number on Matomo',
|
|
145
|
+
'Il codice non può contenere </template>': 'The code cannot contain </template>',
|
|
146
|
+
'Un indirizzo che inizia con / o https://': 'An address starting with / or https://',
|
|
147
|
+
'Scegli un momento nel futuro, almeno tra un minuto': 'Choose a time in the future, at least a minute from now',
|
|
148
|
+
'La pagina non è sul sito: programma prima la pubblicazione': 'The page is not on the site: schedule its publication first',
|
|
149
|
+
'La pagina verrebbe tolta prima di essere pubblicata': 'The page would be removed before it is published',
|
|
150
|
+
'La pubblicazione verrebbe dopo il momento in cui la pagina va tolta': 'The publication would come after the time the page is removed',
|
|
151
|
+
'La raccolta non esiste più': 'The collection no longer exists',
|
|
152
|
+
'Alcuni campi vanno completati: {fields}': 'Some fields need to be completed: {fields}',
|
|
153
|
+
'Alcuni campi vanno completati': 'Some fields need to be completed',
|
|
154
|
+
'La pagina non esiste più': 'The page no longer exists',
|
|
155
|
+
'Errore imprevisto': 'Unexpected error',
|
|
156
|
+
'Il rilascio è vuoto': 'The release is empty',
|
|
157
|
+
'{why} (già uscite: {done})': '{why} (already out: {done})',
|
|
158
|
+
'Questo rilascio non esiste': 'This release does not exist',
|
|
159
|
+
'Questo rilascio è già uscito o è stato annullato': 'This release is already out or was canceled',
|
|
160
|
+
'La pagina non è sul sito: non c’è niente da togliere': 'The page is not on the site: there is nothing to remove',
|
|
161
|
+
'Alcune pagine non sono pronte: non è uscito niente': 'Some pages aren’t ready: nothing went out',
|
|
162
|
+
'Il rilascio non è uscito': 'The release did not go out',
|
|
163
|
+
'Aggiungi prima qualcosa al rilascio': 'Add something to the release first',
|
|
164
|
+
'Questo iscritto non esiste': 'This subscriber does not exist',
|
|
165
|
+
'Nel file non ho trovato indirizzi email': 'No email addresses found in the file',
|
|
166
|
+
'Questa newsletter non esiste': 'This newsletter does not exist',
|
|
167
|
+
'Questa newsletter è già partita: non si può più cambiare': 'This newsletter has already gone out: it can no longer change',
|
|
168
|
+
'La newsletter sta partendo: aspetta che finisca': 'The newsletter is being sent: wait for it to finish',
|
|
169
|
+
'Per inviare la newsletter serve l’email: Impostazioni → Email': 'Sending the newsletter needs email: Settings → Email',
|
|
170
|
+
'[Prova] {subject}': '[Test] {subject}',
|
|
171
|
+
'Non ci sono ancora iscritti attivi': 'There are no active subscribers yet',
|
|
172
|
+
'Non ci sono ancora articoli pubblicati da raccontare': 'There are no published articles to write about yet',
|
|
173
|
+
'La risposta dell’AI non era leggibile: riprova': 'The AI’s reply could not be read: try again',
|
|
174
|
+
'L’email non è configurata': 'Email is not set up',
|
|
175
|
+
'Non più iscritto': 'No longer subscribed',
|
|
176
|
+
'Nessun invio riuscito: controlla Impostazioni → Email': 'No email went out: check Settings → Email',
|
|
177
|
+
'Questo sito non ha pagine da mostrare in anteprima': 'This site has no pages to preview',
|
|
178
|
+
'Questo contenuto non ha una pagina sul sito': 'This content has no page on the site',
|
|
179
|
+
'Link non trovato': 'Link not found',
|
|
180
|
+
'La lingua principale deve essere tra quelle del sito': 'The main language must be one of the site’s languages',
|
|
181
|
+
'Questa lingua esiste già: aprila dall’elenco delle lingue': 'This language already exists: open it from the list of languages',
|
|
182
|
+
'"{label}" esiste già in questa lingua': '"{label}" already exists in this language',
|
|
183
|
+
'Questa è la versione originale': 'This is the original version',
|
|
184
|
+
'Il sito non risponde: {message}': 'The site does not answer: {message}',
|
|
185
|
+
'la pagina non c’è più': 'the page is gone',
|
|
186
|
+
'la pagina è vuota': 'the page is empty',
|
|
187
|
+
'troppo grandi (oltre 25 MB)': 'too large (over 25 MB)',
|
|
188
|
+
'non raggiungibili': 'not reachable',
|
|
189
|
+
'non trovate sul sito ({status})': 'not found on the site ({status})',
|
|
190
|
+
'SVG: la libreria non li accetta, perché possono contenere codice': 'SVG: the library does not accept them, since they can contain code',
|
|
191
|
+
'in un formato che la libreria non legge': 'in a format the library cannot read',
|
|
192
|
+
'La richiesta è troppo grande': 'The request is too large',
|
|
193
|
+
'Troppe prove: riprova tra un’ora': 'Too many tries: try again in an hour',
|
|
194
|
+
'L’indirizzo del sito non è impostato': 'The site address is not set',
|
|
195
|
+
'Senza, “Password dimenticata?” non manda il link: la richiesta può dichiarare un indirizzo falso. Imposta {env} nell’ambiente del server, o site.url in materialize.config.ts.': 'Without it, “Forgot password?” sends no link: the request could claim a false address. Set {env} in the server’s environment, or site.url in materialize.config.ts.',
|
|
196
|
+
// Pieces of saved messages (a release that did not go out)
|
|
197
|
+
'{list}': '{list}',
|
|
198
|
+
'{title}: {message}': '{title}: {message}',
|
|
199
|
+
};
|
package/src/en/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { api } from './api.js';
|
|
2
|
+
import { services } from './services.js';
|
|
3
|
+
/**
|
|
4
|
+
* The English texts of @materializecms/server, by area. Keys are the Italian texts exactly as written
|
|
5
|
+
* in the code (with their {placeholders}); a test checks that every text passed to t() is here.
|
|
6
|
+
*/
|
|
7
|
+
export const en = { ...api, ...services };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** English texts of the server's services: mail, backups, storage, AI, imports, the status page. */
|
|
2
|
+
export const services = {
|
|
3
|
+
'Nuovo messaggio dal modulo del sito (pagina {page}).': 'New message from the site form (page {page}).',
|
|
4
|
+
'Nuovo messaggio dal modulo del sito.': 'New message from the site form.',
|
|
5
|
+
'Il server «{host}» non esiste': 'The server “{host}” does not exist',
|
|
6
|
+
'Controlla il nome del server: un errore di battitura basta.': 'Check the server name: a single typo is enough.',
|
|
7
|
+
'La sicurezza non corrisponde alla porta': 'The security setting does not match the port',
|
|
8
|
+
'Di solito: porta 587 con STARTTLS, porta 465 con TLS.': 'Usually: port 587 with STARTTLS, port 465 with TLS.',
|
|
9
|
+
'Il certificato del server di posta non è valido': 'The mail server’s certificate is not valid',
|
|
10
|
+
'Controlla il nome del server: deve essere esattamente quello indicato dal fornitore.': 'Check the server name: it must be exactly the one your provider gives.',
|
|
11
|
+
'Il server non risponde sulla porta {port}, ma risponde sulla {open}': 'The server does not answer on port {port}, but it answers on {open}',
|
|
12
|
+
' e sulla ': ' and on ',
|
|
13
|
+
'Prova la porta {try} con TLS: la {port} potrebbe essere bloccata dal fornitore del server.': 'Try port {try} with TLS: port {port} may be blocked by your server provider.',
|
|
14
|
+
'Prova la porta {try} con STARTTLS: la {port} potrebbe essere bloccata dal fornitore del server.': 'Try port {try} with STARTTLS: port {port} may be blocked by your server provider.',
|
|
15
|
+
'Il server di posta non risponde (porta {port})': 'The mail server does not answer (port {port})',
|
|
16
|
+
'Molti fornitori di VPS bloccano le porte della posta in uscita, soprattutto per gli account nuovi: chiedi al loro supporto di sbloccarle, oppure usa un servizio che accetta la porta 2525.': 'Many VPS providers block outgoing mail ports, especially on new accounts: ask their support to unblock them, or use a service that accepts port 2525.',
|
|
17
|
+
'Utente o password rifiutati dal server di posta': 'The mail server refused the username or password',
|
|
18
|
+
'Controlla utente e password.': 'Check the username and password.',
|
|
19
|
+
'Il server di posta non accetta questo mittente': 'The mail server does not accept this sender',
|
|
20
|
+
'Il mittente deve essere un indirizzo o un dominio verificato presso il fornitore.': 'The sender must be an address or domain verified with your provider.',
|
|
21
|
+
'Il destinatario è stato rifiutato': 'The recipient was refused',
|
|
22
|
+
'Controlla l’indirizzo del destinatario dei moduli.': 'Check the address that receives form messages.',
|
|
23
|
+
'Il server di posta ha rimandato l’invio': 'The mail server postponed sending',
|
|
24
|
+
'Spesso è un limite di messaggi raggiunto: riprova più tardi o controlla il piano del fornitore.': 'Often a message limit has been reached: try again later or check your provider’s plan.',
|
|
25
|
+
'Invio non riuscito': 'Sending failed',
|
|
26
|
+
'Manca il record SPF': 'The SPF record is missing',
|
|
27
|
+
'Senza SPF le email da {domain} finiscono spesso nello spam. Aggiungi nel DNS un record TXT su {domain}: v=spf1 include:{host} ~all': 'Without SPF, emails from {domain} often end up in spam. Add a TXT record on {domain} in your DNS: v=spf1 include:{host} ~all',
|
|
28
|
+
'Senza SPF le email da {domain} finiscono spesso nello spam. Il fornitore della posta indica quale record TXT aggiungere nel DNS.': 'Without SPF, emails from {domain} often end up in spam. Your mail provider tells you which TXT record to add in your DNS.',
|
|
29
|
+
'Ci sono due record SPF': 'There are two SPF records',
|
|
30
|
+
'I server di posta li scartano entrambi: uniscili in un solo record TXT che inizia con v=spf1.': 'Mail servers discard both: merge them into a single TXT record starting with v=spf1.',
|
|
31
|
+
'Il record SPF non autorizza nessun server': 'The SPF record does not authorize any server',
|
|
32
|
+
'{record}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore (include:{host}).': '{record}: this way the domain declares it sends no email, and messages are rejected. Add your provider’s server (include:{host}).',
|
|
33
|
+
'{record}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore.': '{record}: this way the domain declares it sends no email, and messages are rejected. Add your provider’s server.',
|
|
34
|
+
'Il record SPF non autorizza {provider}': 'The SPF record does not authorize {provider}',
|
|
35
|
+
'Aggiungi include:{host} al record attuale, prima di ~all o -all: {record}': 'Add include:{host} to the current record, before ~all or -all: {record}',
|
|
36
|
+
'Record SPF presente': 'SPF record found',
|
|
37
|
+
'Firma DKIM: da controllare nel pannello del fornitore': 'DKIM signature: check it in your provider’s panel',
|
|
38
|
+
'{provider} dà a ogni account nomi propri per la firma DKIM: nel suo pannello vedi se il dominio è verificato.': '{provider} gives each account its own names for the DKIM signature: its panel shows whether the domain is verified.',
|
|
39
|
+
'Firma DKIM presente': 'DKIM signature found',
|
|
40
|
+
'Trovata con il nome {name}.': 'Found under the name {name}.',
|
|
41
|
+
'Firma DKIM non trovata': 'DKIM signature not found',
|
|
42
|
+
'Non l’ho trovata con i nomi più comuni di {provider}. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.': 'Not found under {provider}’s most common names. Turn on domain signing in your provider’s panel: it gives you the records to add in your DNS.',
|
|
43
|
+
'Non l’ho trovata con i nomi più comuni. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.': 'Not found under the most common names. Turn on domain signing in your provider’s panel: it gives you the records to add in your DNS.',
|
|
44
|
+
'Manca il record DMARC': 'The DMARC record is missing',
|
|
45
|
+
'Gmail e Yahoo lo chiedono a chi invia molte email. Un inizio sicuro, che osserva senza bloccare niente: record TXT su _dmarc.{domain} con v=DMARC1; p=none': 'Gmail and Yahoo require it from bulk senders. A safe start that only monitors and blocks nothing: a TXT record on _dmarc.{domain} with v=DMARC1; p=none',
|
|
46
|
+
'Record DMARC presente': 'DMARC record found',
|
|
47
|
+
'Osserva soltanto (p=none): {record}': 'Monitoring only (p=none): {record}',
|
|
48
|
+
'Il pannello non è ancora compilato': 'The panel has not been built yet',
|
|
49
|
+
'Esegui {command} nella cartella di Materialize, poi ricarica la pagina.': 'Run {command} in the Materialize folder, then reload the page.',
|
|
50
|
+
'Errore del sito': 'Site error',
|
|
51
|
+
'{site} è online': '{site} is online',
|
|
52
|
+
'{n} collezione': '{n} collection',
|
|
53
|
+
'{n} collezioni': '{n} collections',
|
|
54
|
+
'lingue: {list}': 'languages: {list}',
|
|
55
|
+
'Configura il pannello': 'Set up the panel',
|
|
56
|
+
'Apri il pannello': 'Open the panel',
|
|
57
|
+
'Solo indirizzi http e https': 'Only http and https addresses',
|
|
58
|
+
'Questo indirizzo non è pubblico': 'This address is not public',
|
|
59
|
+
'Reindirizzamento senza destinazione ({status})': 'Redirect without a destination ({status})',
|
|
60
|
+
'Il file è troppo grande': 'The file is too large',
|
|
61
|
+
'Troppi reindirizzamenti': 'Too many redirects',
|
|
62
|
+
'Il server ha risposto {status}': 'The server answered {status}',
|
|
63
|
+
'accesso rifiutato: controlla le credenziali e i permessi': 'access denied: check the credentials and permissions',
|
|
64
|
+
'non trovato: controlla l’indirizzo, il bucket o la cartella': 'not found: check the address, the bucket or the folder',
|
|
65
|
+
'una cartella del percorso non esiste: crea almeno quella che contiene la cartella dei backup': 'a folder in the path does not exist: create at least the one that holds the backup folder',
|
|
66
|
+
'spazio esaurito': 'out of space',
|
|
67
|
+
'il servizio ha risposto {status}': 'the service answered {status}',
|
|
68
|
+
'Il servizio {host} non si trova: controlla l’indirizzo': 'The service {host} cannot be found: check the address',
|
|
69
|
+
'Il servizio non risponde ({cause})': 'The service does not answer ({cause})',
|
|
70
|
+
'Risposta vuota': 'Empty response',
|
|
71
|
+
'Il download si è interrotto ({cause}): riprova': 'The download was interrupted ({cause}): try again',
|
|
72
|
+
'Creazione della cartella dei backup': 'Creating the backup folder',
|
|
73
|
+
'Creazione della cartella {dir}': 'Creating the folder {dir}',
|
|
74
|
+
'Caricamento di {key}': 'Uploading {key}',
|
|
75
|
+
'Download di {key}': 'Downloading {key}',
|
|
76
|
+
'Eliminazione di {key}': 'Deleting {key}',
|
|
77
|
+
'Elenco dei file': 'Listing the files',
|
|
78
|
+
'La chiave di ripristino non è valida: deve iniziare con mzk1-': 'The recovery key is not valid: it must start with mzk1-',
|
|
79
|
+
'Non è un file di backup cifrato da Materialize': 'This is not a backup file encrypted by Materialize',
|
|
80
|
+
'Il file è incompleto': 'The file is incomplete',
|
|
81
|
+
'Il file è danneggiato': 'The file is damaged',
|
|
82
|
+
'Chiave sbagliata, oppure il file è stato modificato': 'Wrong key, or the file has been changed',
|
|
83
|
+
'Il file è incompleto o è stato modificato': 'The file is incomplete or has been changed',
|
|
84
|
+
'La copia fuori dal server non è configurata': 'The off-site copy is not set up',
|
|
85
|
+
'Scegli S3 o WebDAV e compila i campi': 'Choose S3 or WebDAV and fill in the fields',
|
|
86
|
+
'Il file di prova è tornato diverso': 'The test file came back different',
|
|
87
|
+
'Il file non è un’immagine leggibile né un modello 3D (.glb)': 'The file is not a readable image or a 3D model (.glb)',
|
|
88
|
+
'Formato non supportato: usa JPG, PNG, WebP, AVIF o GIF': 'Unsupported format: use JPG, PNG, WebP, AVIF or GIF',
|
|
89
|
+
'La richiesta è stata rifiutata dal modello.': 'The model refused the request.',
|
|
90
|
+
'Mi sono fermato: servivano troppi passaggi. Dimmi se continuare.': 'I stopped: it needed too many steps. Tell me whether to go on.',
|
|
91
|
+
'La chiave API di Anthropic non è valida.': 'The Anthropic API key is not valid.',
|
|
92
|
+
'La chiave non ha accesso a questo modello.': 'The key has no access to this model.',
|
|
93
|
+
'Il modello “{model}” non esiste.': 'The model “{model}” does not exist.',
|
|
94
|
+
'Troppe richieste ad Anthropic: riprova tra poco.': 'Too many requests to Anthropic: try again shortly.',
|
|
95
|
+
'Questa conversazione non può continuare: iniziane una nuova con ↺ in alto.': 'This conversation cannot go on: start a new one with ↺ at the top.',
|
|
96
|
+
'Richiesta non valida: {detail}': 'Invalid request: {detail}',
|
|
97
|
+
'Anthropic non risponde ({status}).': 'Anthropic is not answering ({status}).',
|
|
98
|
+
rete: 'network',
|
|
99
|
+
'Il server AI non risponde su {base}.': 'The AI server is not answering at {base}.',
|
|
100
|
+
'Il server AI ha risposto {status}.': 'The AI server answered {status}.',
|
|
101
|
+
'Il server AI ha dato una risposta vuota.': 'The AI server gave an empty answer.',
|
|
102
|
+
'Il modello non ha restituito la traduzione completa: riprova.': 'The model did not return the full translation: try again.',
|
|
103
|
+
'Interrotto.': 'Stopped.',
|
|
104
|
+
'Propongo colori e caratteri': 'Proposing colors and fonts',
|
|
105
|
+
'Propongo la voce del brand': 'Proposing the brand voice',
|
|
106
|
+
'Leggo la pagina': 'Reading the page',
|
|
107
|
+
'Leggo un elemento': 'Reading an element',
|
|
108
|
+
'Modifico la pagina': 'Editing the page',
|
|
109
|
+
'Guardo il sito': 'Looking at the site',
|
|
110
|
+
'Elenco i contenuti': 'Listing the content',
|
|
111
|
+
'Leggo un contenuto': 'Reading an entry',
|
|
112
|
+
'Creo un contenuto': 'Creating an entry',
|
|
113
|
+
'Modifico un contenuto': 'Editing an entry',
|
|
114
|
+
Pubblico: 'Publishing',
|
|
115
|
+
'Tolgo dal sito': 'Unpublishing',
|
|
116
|
+
Programmo: 'Scheduling',
|
|
117
|
+
'Creo un link di anteprima': 'Creating a preview link',
|
|
118
|
+
'Guardo i rilasci': 'Looking at the releases',
|
|
119
|
+
'Creo un rilascio': 'Creating a release',
|
|
120
|
+
'Aggiungo al rilascio': 'Adding to the release',
|
|
121
|
+
'Pubblico il rilascio': 'Publishing the release',
|
|
122
|
+
'Programmo il rilascio': 'Scheduling the release',
|
|
123
|
+
'Guardo le lingue': 'Looking at the languages',
|
|
124
|
+
Traduco: 'Translating',
|
|
125
|
+
'Guardo la libreria di sezioni': 'Looking at the section library',
|
|
126
|
+
'Leggo il tema': 'Reading the theme',
|
|
127
|
+
'Modifico il tema': 'Editing the theme',
|
|
128
|
+
'Guardo la libreria media': 'Looking at the media library',
|
|
129
|
+
'Aggiungo un’immagine dal web': 'Adding an image from the web',
|
|
130
|
+
'Non trovo l’API di WordPress a questo indirizzo: controlla che sia il sito giusto, oppure usa il file di esportazione.': 'I can’t find the WordPress API at this address: check that it is the right site, or use the export file.',
|
|
131
|
+
'WordPress non dà accesso ai contenuti: controlla utente e password applicativa.': 'WordPress does not give access to the content: check the username and application password.',
|
|
132
|
+
'WordPress ha risposto {status} leggendo {route}': 'WordPress answered {status} while reading {route}',
|
|
133
|
+
'Questo non è un file di esportazione di WordPress (Strumenti → Esporta).': 'This is not a WordPress export file (Tools → Export).',
|
|
134
|
+
Sezione: 'Section',
|
|
135
|
+
Immagine: 'Image',
|
|
136
|
+
Pulsante: 'Button',
|
|
137
|
+
'Punto elenco': 'List item',
|
|
138
|
+
Titolo: 'Title',
|
|
139
|
+
Citazione: 'Quote',
|
|
140
|
+
Testo: 'Text',
|
|
141
|
+
'indirizzo già usato da un’altra pagina importata: ora è {now}, era {before}': 'address already used by another imported page: now it is {now}, it was {before}',
|
|
142
|
+
'c’è già un contenuto con lo stesso indirizzo': 'there is already an entry with the same address',
|
|
143
|
+
'importato come bozza: ha campi da completare': 'imported as a draft: it has fields to fill in',
|
|
144
|
+
'impaginazione semplice lasciata: {why}': 'simple layout kept: {why}',
|
|
145
|
+
'l’AI non ha rifatto l’impaginazione: {detail}': 'the AI did not redo the layout: {detail}',
|
|
146
|
+
'la pagina sarebbe rimasta vuota': 'the page would have been left empty',
|
|
147
|
+
'l’AI avrebbe tolto parte del testo': 'the AI would have removed part of the text',
|
|
148
|
+
'l’AI avrebbe aggiunto testi che non c’erano': 'the AI would have added texts that were not there',
|
|
149
|
+
'l’AI avrebbe tolto delle immagini': 'the AI would have removed some images',
|
|
150
|
+
// Spam reasons, saved with each message and read in the language of whoever opens it
|
|
151
|
+
'Compilato in meno di 3 secondi': 'Filled in under 3 seconds',
|
|
152
|
+
'Link nascosti nel testo': 'Links hidden in the text',
|
|
153
|
+
'Troppi link ({n})': 'Too many links ({n})',
|
|
154
|
+
};
|
package/src/forms.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export declare function readSubmission(raw: Record<string, unknown>, def: FormDe
|
|
|
30
30
|
}>;
|
|
31
31
|
/**
|
|
32
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.
|
|
33
|
+
* hidden in markup. Null for a message that looks real. Repeats are checked by the caller. The
|
|
34
|
+
* reason is saved with the message and read in the language of whoever opens it (t.keep).
|
|
34
35
|
*/
|
|
35
36
|
export declare function spamReason(data: Record<string, string>, elapsedMs: number | null): string | null;
|
|
36
37
|
/** The same words from the same form: two identical messages in a day are a repeat. */
|
|
@@ -42,6 +43,8 @@ export declare const removeFiles: (dir: string, id: string) => Promise<void>;
|
|
|
42
43
|
export declare const webhookSignature: (secret: string, body: string) => string;
|
|
43
44
|
/**
|
|
44
45
|
* 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
|
+
* longer each time. Resolves with whether it was taken. What visitors wrote goes there, so an
|
|
47
|
+
* address on this machine or its network is refused (a stolen admin session could otherwise reach
|
|
48
|
+
* services that trust the local network), unless `allowPrivate` (an n8n beside the site, on purpose).
|
|
46
49
|
*/
|
|
47
|
-
export declare function sendWebhook(url: string, payload: unknown, secret: string, wait?: (ms: number) => Promise<void
|
|
50
|
+
export declare function sendWebhook(url: string, payload: unknown, secret: string, wait?: (ms: number) => Promise<void>, allowPrivate?: boolean): Promise<boolean>;
|
package/src/forms.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash, createHmac } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { extname, join } from 'node:path';
|
|
4
4
|
import { FILE_TYPES, FORM_FILE_MAX } from '@materializecms/core/page';
|
|
5
|
+
import { RemoteError, remoteFetch } from './remote.js';
|
|
6
|
+
import { t } from './t.js';
|
|
5
7
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
8
|
const isFile = (v) => typeof v === 'object' && v !== null && 'arrayBuffer' in v && typeof v.name === 'string';
|
|
7
9
|
/**
|
|
@@ -77,17 +79,18 @@ export async function readSubmission(raw, def) {
|
|
|
77
79
|
const MIN_FILL_MS = 2500;
|
|
78
80
|
/**
|
|
79
81
|
* 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.
|
|
82
|
+
* hidden in markup. Null for a message that looks real. Repeats are checked by the caller. The
|
|
83
|
+
* reason is saved with the message and read in the language of whoever opens it (t.keep).
|
|
81
84
|
*/
|
|
82
85
|
export function spamReason(data, elapsedMs) {
|
|
83
86
|
if (elapsedMs !== null && elapsedMs >= 0 && elapsedMs < MIN_FILL_MS)
|
|
84
|
-
return 'Compilato in meno di 3 secondi';
|
|
87
|
+
return t.keep('Compilato in meno di 3 secondi');
|
|
85
88
|
const text = Object.values(data).join('\n');
|
|
86
89
|
if (/\[url=|<a\s+href|\[link=/i.test(text))
|
|
87
|
-
return 'Link nascosti nel testo';
|
|
90
|
+
return t.keep('Link nascosti nel testo');
|
|
88
91
|
const links = text.match(/https?:\/\/|www\./gi)?.length ?? 0;
|
|
89
92
|
if (links > 3)
|
|
90
|
-
return
|
|
93
|
+
return t.keep('Troppi link ({n})', { n: links });
|
|
91
94
|
return null;
|
|
92
95
|
}
|
|
93
96
|
/** The same words from the same form: two identical messages in a day are a repeat. */
|
|
@@ -122,15 +125,17 @@ export const removeFiles = (dir, id) => rm(join(dir, id), { recursive: true, for
|
|
|
122
125
|
export const webhookSignature = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
|
|
123
126
|
/**
|
|
124
127
|
* 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.
|
|
128
|
+
* longer each time. Resolves with whether it was taken. What visitors wrote goes there, so an
|
|
129
|
+
* address on this machine or its network is refused (a stolen admin session could otherwise reach
|
|
130
|
+
* services that trust the local network), unless `allowPrivate` (an n8n beside the site, on purpose).
|
|
126
131
|
*/
|
|
127
|
-
export async function sendWebhook(url, payload, secret, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
132
|
+
export async function sendWebhook(url, payload, secret, wait = (ms) => new Promise((r) => setTimeout(r, ms)), allowPrivate = false) {
|
|
128
133
|
const body = JSON.stringify(payload);
|
|
129
134
|
for (const delay of [0, 20_000, 120_000]) {
|
|
130
135
|
if (delay)
|
|
131
136
|
await wait(delay);
|
|
132
137
|
try {
|
|
133
|
-
const res = await
|
|
138
|
+
const res = await remoteFetch(url, {
|
|
134
139
|
method: 'POST',
|
|
135
140
|
headers: {
|
|
136
141
|
'content-type': 'application/json',
|
|
@@ -138,16 +143,22 @@ export async function sendWebhook(url, payload, secret, wait = (ms) => new Promi
|
|
|
138
143
|
'x-materialize-signature': webhookSignature(secret, body),
|
|
139
144
|
},
|
|
140
145
|
body,
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
max: 1024 * 1024,
|
|
147
|
+
maxRedirects: 0,
|
|
148
|
+
timeout: 10_000,
|
|
149
|
+
allowPrivate,
|
|
143
150
|
});
|
|
144
|
-
if (res.
|
|
151
|
+
if (res.status >= 200 && res.status < 300)
|
|
145
152
|
return true;
|
|
146
153
|
// The receiver refused it on purpose: trying again would not help.
|
|
147
154
|
if (res.status >= 400 && res.status < 500 && res.status !== 429)
|
|
148
155
|
return false;
|
|
149
156
|
}
|
|
150
|
-
catch {
|
|
157
|
+
catch (error) {
|
|
158
|
+
// An address that is not public stays so: no point waiting to try again.
|
|
159
|
+
if (error instanceof RemoteError && error.reason === 'private')
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
151
162
|
}
|
|
152
163
|
return false;
|
|
153
164
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -32,4 +32,17 @@ export interface ServerOptions {
|
|
|
32
32
|
/** Required by the installation in the browser while there is no account yet. */
|
|
33
33
|
setupToken?: string;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether the visitor reached the site over HTTPS: directly, through a proxy the config trusts, or at
|
|
37
|
+
* a site whose address is https (not on this computer). The same rule as the session cookie in app.ts.
|
|
38
|
+
*/
|
|
39
|
+
export declare function secureSiteRequest(req: IncomingMessage, config: MaterializeConfig): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The security headers of every page of the public site, in dev and production (pages, 404, feeds,
|
|
42
|
+
* static files, redirects): the site skips Hono, so its secureHeaders never sees these requests.
|
|
43
|
+
* Set before the renderer runs, so the headers it writes (x-robots-tag on share links) add to these.
|
|
44
|
+
* Framing stays allowed from the same origin, where the editor previews the site; no script rules,
|
|
45
|
+
* since sites run their own code and tracking scripts.
|
|
46
|
+
*/
|
|
47
|
+
export declare function setSiteHeaders(req: IncomingMessage, res: ServerResponse, config: MaterializeConfig): void;
|
|
35
48
|
export declare function startServer(initial: MaterializeConfig, options?: ServerOptions): Promise<RunningServer>;
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getRequestListener } from '@hono/node-server';
|
|
|
3
3
|
import { ADMIN_DIST } from '@materializecms/admin';
|
|
4
4
|
import { openDatabase } from '@materializecms/db';
|
|
5
5
|
import { createApp } from './app.js';
|
|
6
|
+
import { t } from './t.js';
|
|
6
7
|
export { isPatch, newer, } from '@materializecms/core/updates';
|
|
7
8
|
export { createApp, VERSION } from './app.js';
|
|
8
9
|
export { extractTarGz } from './archive.js';
|
|
@@ -14,6 +15,46 @@ export { decryptTree, parseRecoveryKey } from './offsite.js';
|
|
|
14
15
|
export { hashPassword, verifyPassword } from './password.js';
|
|
15
16
|
export { apiCaller, siteTools } from './tools.js';
|
|
16
17
|
const OWN = /^\/(api|admin|media|fonts|mcp)(\/|\?|$)/;
|
|
18
|
+
/** This computer: browsers may refuse HSTS over plain http, and it would stick to localhost for months. */
|
|
19
|
+
const isLocalHost = (hostname) => hostname === 'localhost' ||
|
|
20
|
+
hostname.endsWith('.localhost') ||
|
|
21
|
+
/^127\.\d+\.\d+\.\d+$/.test(hostname) ||
|
|
22
|
+
hostname === '[::1]';
|
|
23
|
+
/**
|
|
24
|
+
* Whether the visitor reached the site over HTTPS: directly, through a proxy the config trusts, or at
|
|
25
|
+
* a site whose address is https (not on this computer). The same rule as the session cookie in app.ts.
|
|
26
|
+
*/
|
|
27
|
+
export function secureSiteRequest(req, config) {
|
|
28
|
+
if (req.socket.encrypted)
|
|
29
|
+
return true;
|
|
30
|
+
const proto = req.headers['x-forwarded-proto'];
|
|
31
|
+
if (config.server.trustProxy &&
|
|
32
|
+
String(proto ?? '')
|
|
33
|
+
.split(',')[0]
|
|
34
|
+
?.trim() === 'https')
|
|
35
|
+
return true;
|
|
36
|
+
let hostname = 'localhost';
|
|
37
|
+
try {
|
|
38
|
+
hostname = new URL(`http://${req.headers.host ?? 'localhost'}`).hostname;
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
return !isLocalHost(hostname) && !!config.site.url?.startsWith('https:');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The security headers of every page of the public site, in dev and production (pages, 404, feeds,
|
|
45
|
+
* static files, redirects): the site skips Hono, so its secureHeaders never sees these requests.
|
|
46
|
+
* Set before the renderer runs, so the headers it writes (x-robots-tag on share links) add to these.
|
|
47
|
+
* Framing stays allowed from the same origin, where the editor previews the site; no script rules,
|
|
48
|
+
* since sites run their own code and tracking scripts.
|
|
49
|
+
*/
|
|
50
|
+
export function setSiteHeaders(req, res, config) {
|
|
51
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
52
|
+
res.setHeader('referrer-policy', 'strict-origin-when-cross-origin');
|
|
53
|
+
res.setHeader('x-frame-options', 'SAMEORIGIN');
|
|
54
|
+
res.setHeader('content-security-policy', "frame-ancestors 'self'");
|
|
55
|
+
if (secureSiteRequest(req, config))
|
|
56
|
+
res.setHeader('strict-transport-security', 'max-age=15552000');
|
|
57
|
+
}
|
|
17
58
|
export async function startServer(initial, options = {}) {
|
|
18
59
|
let config = initial;
|
|
19
60
|
const database = await openDatabase(config.database);
|
|
@@ -30,10 +71,19 @@ export async function startServer(initial, options = {}) {
|
|
|
30
71
|
const site = options.site;
|
|
31
72
|
const server = createServer((req, res) => {
|
|
32
73
|
if (site && !OWN.test(req.url ?? '/')) {
|
|
33
|
-
|
|
74
|
+
setSiteHeaders(req, res, config);
|
|
75
|
+
Promise.resolve()
|
|
76
|
+
.then(() => site(req, res))
|
|
77
|
+
.catch((error) => {
|
|
78
|
+
// A malformed address (/%E0%A4%A) is the visitor's mistake, not the site's.
|
|
79
|
+
if (error instanceof URIError) {
|
|
80
|
+
if (!res.headersSent)
|
|
81
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }).end('Bad request');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
34
84
|
console.error(error);
|
|
35
85
|
if (!res.headersSent)
|
|
36
|
-
res.writeHead(500).end('Errore del sito');
|
|
86
|
+
res.writeHead(500).end(t('Errore del sito'));
|
|
37
87
|
});
|
|
38
88
|
return;
|
|
39
89
|
}
|