@materializecms/server 0.7.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 +40 -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 +579 -261
- 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.d.ts +1 -1
- package/src/tools.js +29 -28
- package/src/wordpress.js +12 -11
- package/src/wpimport.js +27 -23
package/src/app.js
CHANGED
|
@@ -8,8 +8,9 @@ import { Readable } from 'node:stream';
|
|
|
8
8
|
import { getConnInfo } from '@hono/node-server/conninfo';
|
|
9
9
|
import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
|
|
10
10
|
import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
|
|
11
|
+
import { currentLang, intlLocaleOf, isLang } from '@materializecms/core/lang';
|
|
11
12
|
import { senderDomain } from '@materializecms/core/mail';
|
|
12
|
-
import { expandComponents, expandGlobals, formDefinition, normalizeTheme, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
|
|
13
|
+
import { expandComponents, expandGlobals, formDefinition, normalizeTheme, OVERRIDE_KEYS, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
|
|
13
14
|
import { addMove, feedCollections, normalizePath } from '@materializecms/core/seo';
|
|
14
15
|
import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
|
|
15
16
|
import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
|
|
@@ -31,6 +32,7 @@ import { createBackups, DEFAULT_BACKUPS } from './backups.js';
|
|
|
31
32
|
import { briefSystem, briefTools, plain } from './brief.js';
|
|
32
33
|
import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
|
|
33
34
|
import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
|
|
35
|
+
import { requestLang, useProfileLang, withLang } from './lang.js';
|
|
34
36
|
import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
|
|
35
37
|
import { createMcpServer } from './mcp.js';
|
|
36
38
|
import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
|
|
@@ -40,6 +42,7 @@ import { hashPassword, verifyPassword } from './password.js';
|
|
|
40
42
|
import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
|
|
41
43
|
import { statusPage } from './status-page.js';
|
|
42
44
|
import { studioSystem, studioTools } from './studio.js';
|
|
45
|
+
import { t } from './t.js';
|
|
43
46
|
import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
|
|
44
47
|
import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
|
|
45
48
|
import { readRest, readWxr, WordPressError } from './wordpress.js';
|
|
@@ -78,32 +81,80 @@ function embedsOf(value, out = []) {
|
|
|
78
81
|
const node = value;
|
|
79
82
|
if (node.type === 'embed' && typeof node.id === 'string')
|
|
80
83
|
out.push(`${node.id}:${String(node.html ?? '')}`);
|
|
81
|
-
// Where a form's messages go
|
|
84
|
+
// Where a form's messages go, and the email it sends back to whoever writes (to any address a
|
|
85
|
+
// visitor types): someone who only edits pages must not divert them or write that email.
|
|
82
86
|
const form = node.form;
|
|
83
|
-
if (form && typeof node.id === 'string' && (form.to || form.webhook))
|
|
84
|
-
out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}`);
|
|
87
|
+
if (form && typeof node.id === 'string' && (form.to || form.webhook || form.autoReply))
|
|
88
|
+
out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}:${JSON.stringify(form.autoReply ?? null)}`);
|
|
89
|
+
// A component's copy keeps only its own words, links and images; anything else in its overrides
|
|
90
|
+
// (a type, custom code, a form) would turn an element of the component into something the
|
|
91
|
+
// checks above never see by its id.
|
|
92
|
+
const overrides = node.overrides;
|
|
93
|
+
if (overrides && typeof overrides === 'object' && typeof node.id === 'string')
|
|
94
|
+
for (const [child, own] of Object.entries(overrides)) {
|
|
95
|
+
const extra = own && typeof own === 'object'
|
|
96
|
+
? Object.keys(own).filter((k) => !OVERRIDE_KEYS.includes(k))
|
|
97
|
+
: [];
|
|
98
|
+
if (extra.length)
|
|
99
|
+
out.push(`override:${node.id}:${child}:${JSON.stringify(Object.fromEntries(extra.map((k) => [k, own[k]])))}`);
|
|
100
|
+
}
|
|
85
101
|
for (const v of Object.values(node))
|
|
86
102
|
if (v && typeof v === 'object')
|
|
87
103
|
embedsOf(v, out);
|
|
88
104
|
}
|
|
89
105
|
return out;
|
|
90
106
|
}
|
|
107
|
+
/** What a form shows to visitors: the rest (who receives it, the webhook, the automatic reply) is the site's own. */
|
|
108
|
+
const PUBLIC_FORM_KEYS = ['name', 'success', 'newsletter'];
|
|
109
|
+
/**
|
|
110
|
+
* A published document as anyone may read it: page elements (and component copies' overrides)
|
|
111
|
+
* keep only the public part of their forms. The site itself reads the database, not this.
|
|
112
|
+
*/
|
|
113
|
+
function withoutFormSettings(value) {
|
|
114
|
+
if (Array.isArray(value))
|
|
115
|
+
return value.map(withoutFormSettings);
|
|
116
|
+
if (!value || typeof value !== 'object')
|
|
117
|
+
return value;
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [k, v] of Object.entries(value)) {
|
|
120
|
+
out[k] =
|
|
121
|
+
k === 'form' && v && typeof v === 'object' && !Array.isArray(v)
|
|
122
|
+
? Object.fromEntries(Object.entries(v).filter(([key]) => PUBLIC_FORM_KEYS.includes(key)))
|
|
123
|
+
: withoutFormSettings(v);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
91
127
|
class ApiError extends HTTPException {
|
|
92
128
|
constructor(status, code, message, issues) {
|
|
93
129
|
super(status, { res: Response.json({ error: { code, message, issues } }, { status }) });
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
|
-
|
|
132
|
+
/** The language of an email to someone with an account: their profile's, else the current one. */
|
|
133
|
+
/**
|
|
134
|
+
* The language of an email to someone with an account: their profile's, else Italian, the panel's
|
|
135
|
+
* default — never the language of whoever made the request (a stranger trying to sign in).
|
|
136
|
+
*/
|
|
137
|
+
const langFor = (locale) => (isLang(locale) ? locale : 'it');
|
|
138
|
+
/** A document's schedules with the reason of the last failure in the reader's language (t.keep). */
|
|
139
|
+
const readSchedule = (schedule) => schedule.failed?.error
|
|
140
|
+
? { ...schedule, failed: { ...schedule.failed, error: t.read(schedule.failed.error) } }
|
|
141
|
+
: schedule;
|
|
142
|
+
/** Longer than any real password: hashing a huge one would only cost the server time. */
|
|
143
|
+
const MAX_PASSWORD = 1000;
|
|
144
|
+
const credentials = z.object({ email: z.email(), password: z.string().min(1).max(MAX_PASSWORD) });
|
|
97
145
|
const setupBody = z.object({
|
|
98
146
|
email: z.email(),
|
|
99
147
|
name: z.string().trim().min(1),
|
|
100
|
-
password: z.string().min(10),
|
|
148
|
+
password: z.string().min(10).max(MAX_PASSWORD),
|
|
101
149
|
/** From the link `materialize start` prints, when it set one. */
|
|
102
150
|
token: z.string().optional(),
|
|
103
151
|
site: z
|
|
104
152
|
.object({
|
|
105
153
|
name: z.string().trim().min(1).max(120).optional(),
|
|
106
|
-
locale: z
|
|
154
|
+
locale: z
|
|
155
|
+
.string()
|
|
156
|
+
.refine(isLocaleCode, { error: () => t('Codice lingua non valido') })
|
|
157
|
+
.optional(),
|
|
107
158
|
})
|
|
108
159
|
.optional(),
|
|
109
160
|
});
|
|
@@ -168,8 +219,17 @@ async function body(c, schema) {
|
|
|
168
219
|
/** Naive in-memory limiter: enough to slow down password guessing on a single instance. */
|
|
169
220
|
function createLimiter(max, windowMs) {
|
|
170
221
|
const hits = new Map();
|
|
222
|
+
let swept = Date.now();
|
|
171
223
|
return (key) => {
|
|
172
224
|
const now = Date.now();
|
|
225
|
+
// Keys come from whoever asks (an address, a form's name): the expired ones go, or the map
|
|
226
|
+
// would only grow.
|
|
227
|
+
if (now - swept > windowMs) {
|
|
228
|
+
swept = now;
|
|
229
|
+
for (const [k, v] of hits)
|
|
230
|
+
if (v.until < now)
|
|
231
|
+
hits.delete(k);
|
|
232
|
+
}
|
|
173
233
|
const entry = hits.get(key);
|
|
174
234
|
if (!entry || entry.until < now) {
|
|
175
235
|
hits.set(key, { n: 1, until: now + windowMs });
|
|
@@ -179,6 +239,28 @@ function createLimiter(max, windowMs) {
|
|
|
179
239
|
return entry.n <= max;
|
|
180
240
|
};
|
|
181
241
|
}
|
|
242
|
+
/** Request bodies, at most: anyone may send one, and it is read into memory. */
|
|
243
|
+
const BODY_MAX = 2 * 1024 * 1024;
|
|
244
|
+
/** For the panel and its tokens: a page built on the canvas, with its components, can weigh a few MB. */
|
|
245
|
+
const SIGNED_IN_BODY_MAX = 10 * 1024 * 1024;
|
|
246
|
+
/** A WordPress export file (WXR): only text, but a large blog's can be big. */
|
|
247
|
+
const WXR_MAX = 200 * 1024 * 1024;
|
|
248
|
+
/** Two addresses of the same server (scheme, host and port), or both empty (the provider's own). */
|
|
249
|
+
const sameServer = (a, b) => {
|
|
250
|
+
if (!a || !b)
|
|
251
|
+
return !a && !b;
|
|
252
|
+
try {
|
|
253
|
+
return new URL(a).origin === new URL(b).origin;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
/** This computer: a Host header that can only bring a link back here, never to someone else's server. */
|
|
260
|
+
const isLocalHost = (hostname) => hostname === 'localhost' ||
|
|
261
|
+
hostname.endsWith('.localhost') ||
|
|
262
|
+
/^127\.\d+\.\d+\.\d+$/.test(hostname) ||
|
|
263
|
+
hostname === '[::1]';
|
|
182
264
|
export function createApp(base) {
|
|
183
265
|
// The languages chosen in Impostazioni → Lingue win over the config's; read once, then kept current.
|
|
184
266
|
let languages = null;
|
|
@@ -228,6 +310,15 @@ export function createApp(base) {
|
|
|
228
310
|
...DEFAULT_OFFSITE,
|
|
229
311
|
...(await settings.get('offsite')),
|
|
230
312
|
});
|
|
313
|
+
/**
|
|
314
|
+
* The language of the people who get the site's notices (a form's messages): the account with
|
|
315
|
+
* that address, else the first administrator's, else Italian.
|
|
316
|
+
*/
|
|
317
|
+
const ownerLang = async (address) => {
|
|
318
|
+
const users = await auth.listUsers();
|
|
319
|
+
const owner = users.find((u) => u.email.toLowerCase() === address.toLowerCase()) ?? users.find((u) => u.role === 'admin');
|
|
320
|
+
return langFor(owner?.locale);
|
|
321
|
+
};
|
|
231
322
|
let offsiteRunning = false;
|
|
232
323
|
/** One run; the outcome is kept, and administrators hear by email the first time it fails. */
|
|
233
324
|
const offsiteRun = async (fresh) => {
|
|
@@ -243,10 +334,13 @@ export function createApp(base) {
|
|
|
243
334
|
status = { at: new Date().toISOString(), ok: false, message: error.message };
|
|
244
335
|
if (before?.ok !== false)
|
|
245
336
|
for (const admin of (await auth.listUsers()).filter((u) => u.role === 'admin'))
|
|
246
|
-
await mailNotice(admin.email, 'Backup fuori dal server non riuscito', [
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
337
|
+
await withLang(langFor(admin.locale), () => mailNotice(admin.email, t('Backup fuori dal server non riuscito'), [
|
|
338
|
+
t('La copia dei backup di {site} fuori dal server non è riuscita: {message}', {
|
|
339
|
+
site: ctx.config().site.name,
|
|
340
|
+
message: status.message,
|
|
341
|
+
}),
|
|
342
|
+
t('Controlla in Impostazioni → Sicurezza → Backup. Ti scriviamo di nuovo solo quando torna a funzionare e si ferma di nuovo.'),
|
|
343
|
+
]));
|
|
250
344
|
}
|
|
251
345
|
finally {
|
|
252
346
|
offsiteRunning = false;
|
|
@@ -323,6 +417,12 @@ export function createApp(base) {
|
|
|
323
417
|
const presence = new Map();
|
|
324
418
|
// A form can be sent a few times a minute from one address: enough for people, not for spam.
|
|
325
419
|
const allowForm = createLimiter(5, 60_000);
|
|
420
|
+
// And from one address across every form: form names are whatever the sender writes.
|
|
421
|
+
const allowFormIp = createLimiter(10, 60_000);
|
|
422
|
+
const allowFormIpHour = createLimiter(60, 60 * 60_000);
|
|
423
|
+
// Forms the site built by hand (no form on a published page): any name goes, so fewer of them.
|
|
424
|
+
const allowHandForm = createLimiter(3, 60_000);
|
|
425
|
+
const privateWebhooks = base.privateWebhooks ?? process.env.MATERIALIZE_PRIVATE_WEBHOOKS === '1';
|
|
326
426
|
const files = createMediaFiles(ctx.config().media.dir);
|
|
327
427
|
// Fonts are cached next to the media library (.materialize/fonts by default).
|
|
328
428
|
const fonts = createFonts(join(dirname(resolve(ctx.config().media.dir)), 'fonts'));
|
|
@@ -339,11 +439,11 @@ export function createApp(base) {
|
|
|
339
439
|
if (user.role === 'admin')
|
|
340
440
|
return;
|
|
341
441
|
if (JSON.stringify(embedsOf(before).sort()) !== JSON.stringify(embedsOf(after).sort()))
|
|
342
|
-
throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli');
|
|
442
|
+
throw new ApiError(403, 'embed_admin_only', t('Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli'));
|
|
343
443
|
};
|
|
344
444
|
/** Editors who may only change contents (Persone → Redattori): words, links and images, not the layout. */
|
|
345
445
|
const contentOnly = async (user) => user.role !== 'admin' && !!(await settings.get('editorial'))?.editorsContentOnly;
|
|
346
|
-
const designLocked = () => new ApiError(403, 'content_only', 'Puoi modificare testi, collegamenti e immagini, non il layout della pagina');
|
|
446
|
+
const designLocked = () => new ApiError(403, 'content_only', t('Puoi modificare testi, collegamenti e immagini, non il layout della pagina'));
|
|
347
447
|
const guardDesign = async (user, before, after) => {
|
|
348
448
|
const pages = (v) => (Array.isArray(v) ? v : []);
|
|
349
449
|
if (!sameDesign(pages(before), pages(after)) && (await contentOnly(user)))
|
|
@@ -399,6 +499,19 @@ export function createApp(base) {
|
|
|
399
499
|
console.error('Materialize: activity not recorded', error);
|
|
400
500
|
}
|
|
401
501
|
};
|
|
502
|
+
/**
|
|
503
|
+
* Whether the browser reached the site over HTTPS: directly, through a proxy the config trusts,
|
|
504
|
+
* or at a site whose address is https (not on this computer, where browsers may refuse a secure
|
|
505
|
+
* cookie over plain http). The session cookie then never travels in clear.
|
|
506
|
+
*/
|
|
507
|
+
const secureRequest = (c) => {
|
|
508
|
+
const url = new URL(c.req.url);
|
|
509
|
+
if (url.protocol === 'https:')
|
|
510
|
+
return true;
|
|
511
|
+
if (ctx.config().server.trustProxy && c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() === 'https')
|
|
512
|
+
return true;
|
|
513
|
+
return !isLocalHost(url.hostname) && !!ctx.config().site.url?.startsWith('https:');
|
|
514
|
+
};
|
|
402
515
|
const startSession = async (c, userId) => {
|
|
403
516
|
const { token, expiresAt } = await auth.createSession(userId, SESSION_TTL, {
|
|
404
517
|
userAgent: c.req.header('user-agent'),
|
|
@@ -407,7 +520,7 @@ export function createApp(base) {
|
|
|
407
520
|
setCookie(c, SESSION_COOKIE, token, {
|
|
408
521
|
httpOnly: true,
|
|
409
522
|
sameSite: 'Lax',
|
|
410
|
-
secure:
|
|
523
|
+
secure: secureRequest(c),
|
|
411
524
|
path: '/',
|
|
412
525
|
expires: expiresAt,
|
|
413
526
|
});
|
|
@@ -424,6 +537,7 @@ export function createApp(base) {
|
|
|
424
537
|
throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
|
|
425
538
|
c.set('user', user);
|
|
426
539
|
c.set('viaToken', true);
|
|
540
|
+
useProfileLang(user.locale);
|
|
427
541
|
return next();
|
|
428
542
|
}
|
|
429
543
|
const token = getCookie(c, SESSION_COOKIE);
|
|
@@ -431,6 +545,7 @@ export function createApp(base) {
|
|
|
431
545
|
if (!user)
|
|
432
546
|
throw new ApiError(401, 'unauthenticated', 'Sign in to continue');
|
|
433
547
|
c.set('user', user);
|
|
548
|
+
useProfileLang(user.locale);
|
|
434
549
|
// When the site requires two-step verification, someone without it can only set it up.
|
|
435
550
|
if (!user.twoFactor && !c.req.path.startsWith('/api/auth/') && (await twoFactorRequired()))
|
|
436
551
|
throw new ApiError(403, 'two_factor_required', 'Set up two-step verification to continue');
|
|
@@ -466,7 +581,7 @@ export function createApp(base) {
|
|
|
466
581
|
url: def?.url ? def.url(doc.data) : null,
|
|
467
582
|
issues: check && !check.ok ? check.issues : [],
|
|
468
583
|
/** When it goes on or off the site by itself, and the last scheduled action that failed. */
|
|
469
|
-
schedule: await schedules.forDocument(doc.id),
|
|
584
|
+
schedule: readSchedule(await schedules.forDocument(doc.id)),
|
|
470
585
|
/** The release it goes out with, if any. */
|
|
471
586
|
release: await releases
|
|
472
587
|
.forDocument(doc.id)
|
|
@@ -503,12 +618,39 @@ export function createApp(base) {
|
|
|
503
618
|
throw error;
|
|
504
619
|
}
|
|
505
620
|
};
|
|
621
|
+
// Library files are the og:image of shared pages and the enclosures of the RSS feed: social previews
|
|
622
|
+
// and feed readers load them from other sites. Registered before secureHeaders, so this runs after it.
|
|
623
|
+
app.use('/media/*', async (c, next) => {
|
|
624
|
+
await next();
|
|
625
|
+
c.res.headers.set('cross-origin-resource-policy', 'cross-origin');
|
|
626
|
+
});
|
|
506
627
|
app.use('*', secureHeaders());
|
|
628
|
+
// Every body is limited before anything reads it: the sign-in, setup and form routes are open to
|
|
629
|
+
// anyone. Uploads and the WordPress export set their own limits (larger, after the sign-in check).
|
|
630
|
+
const ownLimit = /^\/api\/(media|forms\/[^/]+|import\/wordpress\/analyze)$/;
|
|
631
|
+
const limitBody = (max) => bodyLimit({
|
|
632
|
+
maxSize: max,
|
|
633
|
+
onError: () => {
|
|
634
|
+
throw new ApiError(413, 'body_too_large', t('La richiesta è troppo grande'));
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
const smallBody = limitBody(BODY_MAX);
|
|
638
|
+
const signedInBody = limitBody(SIGNED_IN_BODY_MAX);
|
|
639
|
+
const limitRequest = (c, next) => {
|
|
640
|
+
if (c.req.method === 'POST' && ownLimit.test(c.req.path))
|
|
641
|
+
return next();
|
|
642
|
+
// Only the larger size for a request that says who sends it; the sign-in check comes after.
|
|
643
|
+
return (bearerOf(c) || getCookie(c, SESSION_COOKIE) ? signedInBody : smallBody)(c, next);
|
|
644
|
+
};
|
|
645
|
+
app.use('/api/*', limitRequest);
|
|
646
|
+
app.use('/mcp', limitRequest);
|
|
507
647
|
// Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
|
|
508
648
|
const checkOrigin = csrf();
|
|
509
649
|
app.use('/api/*', (c, next) =>
|
|
510
650
|
// Leaving the newsletter with one click from the email app comes with no Origin: the token is the proof.
|
|
511
651
|
bearerOf(c) || c.req.path.startsWith('/api/newsletter/unsubscribe/') ? next() : checkOrigin(c, next));
|
|
652
|
+
// Every request in the language of whoever makes it (see lang.ts).
|
|
653
|
+
app.use('*', requestLang);
|
|
512
654
|
// Answers change with every edit: browsers must not reuse them.
|
|
513
655
|
app.use('/api/*', async (c, next) => {
|
|
514
656
|
await next();
|
|
@@ -553,7 +695,7 @@ export function createApp(base) {
|
|
|
553
695
|
id: 'node',
|
|
554
696
|
label: `Node.js ${process.versions.node}`,
|
|
555
697
|
state: node >= 24 ? 'ok' : 'error',
|
|
556
|
-
detail: node >= 24 ? 'Versione adatta.' : 'Serve Node 24 o più recente.',
|
|
698
|
+
detail: node >= 24 ? t('Versione adatta.') : t('Serve Node 24 o più recente.'),
|
|
557
699
|
});
|
|
558
700
|
const mediaDir = resolve(ctx.config().media.dir);
|
|
559
701
|
let writable = true;
|
|
@@ -566,41 +708,41 @@ export function createApp(base) {
|
|
|
566
708
|
}
|
|
567
709
|
checks.push({
|
|
568
710
|
id: 'files',
|
|
569
|
-
label: 'Cartella dei file',
|
|
711
|
+
label: t('Cartella dei file'),
|
|
570
712
|
state: writable ? 'ok' : 'error',
|
|
571
713
|
detail: writable
|
|
572
|
-
? 'Database e immagini si possono salvare.'
|
|
573
|
-
:
|
|
714
|
+
? t('Database e immagini si possono salvare.')
|
|
715
|
+
: t('Il programma non può scrivere in {dir}: dagli i permessi su quella cartella.', { dir: mediaDir }),
|
|
574
716
|
});
|
|
575
717
|
checks.push({
|
|
576
718
|
id: 'site',
|
|
577
|
-
label: 'Sito',
|
|
719
|
+
label: t('Sito'),
|
|
578
720
|
state: ctx.preview ? 'ok' : 'warn',
|
|
579
721
|
detail: ctx.preview
|
|
580
|
-
? 'Il sito si genera da solo a ogni pubblicazione.'
|
|
581
|
-
: 'Solo il CMS: in questa cartella non c’è un sito Astro.',
|
|
722
|
+
? t('Il sito si genera da solo a ogni pubblicazione.')
|
|
723
|
+
: t('Solo il CMS: in questa cartella non c’è un sito Astro.'),
|
|
582
724
|
});
|
|
583
725
|
const url = new URL(c.req.url);
|
|
584
726
|
const local = /^(localhost|127\.|\[::1\])/.test(url.hostname);
|
|
585
727
|
const https = url.protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https';
|
|
586
728
|
checks.push({
|
|
587
729
|
id: 'https',
|
|
588
|
-
label: 'Connessione sicura',
|
|
730
|
+
label: t('Connessione sicura'),
|
|
589
731
|
state: https || local ? 'ok' : 'warn',
|
|
590
732
|
detail: https
|
|
591
|
-
? 'HTTPS attivo.'
|
|
733
|
+
? t('HTTPS attivo.')
|
|
592
734
|
: local
|
|
593
|
-
? 'In locale non serve HTTPS.'
|
|
594
|
-
: 'Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).',
|
|
735
|
+
? t('In locale non serve HTTPS.')
|
|
736
|
+
: t('Senza HTTPS la password viaggia in chiaro: attivalo prima di metterlo online (guida in docs/sicurezza.md).'),
|
|
595
737
|
});
|
|
596
738
|
const updates = await ctx.updates?.status();
|
|
597
739
|
checks.push({
|
|
598
740
|
id: 'updates',
|
|
599
|
-
label: 'Aggiornamenti',
|
|
741
|
+
label: t('Aggiornamenti'),
|
|
600
742
|
state: updates && !updates.unavailable ? 'ok' : 'warn',
|
|
601
743
|
detail: updates && !updates.unavailable
|
|
602
|
-
? 'Si installano dal pannello, con un clic.'
|
|
603
|
-
: (updates?.unavailable ?? 'Si installano da terminale.'),
|
|
744
|
+
? t('Si installano dal pannello, con un clic.')
|
|
745
|
+
: (updates?.unavailable ?? t('Si installano da terminale.')),
|
|
604
746
|
});
|
|
605
747
|
return checks;
|
|
606
748
|
};
|
|
@@ -630,7 +772,7 @@ export function createApp(base) {
|
|
|
630
772
|
if ((await auth.countUsers()) > 0)
|
|
631
773
|
throw new ApiError(409, 'already_set_up', 'An administrator already exists');
|
|
632
774
|
if (ctx.setupToken && input.token !== ctx.setupToken)
|
|
633
|
-
throw new ApiError(403, 'setup_token', 'Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice');
|
|
775
|
+
throw new ApiError(403, 'setup_token', t('Per installare serve il link stampato all’avvio del server (materialize start), con il suo codice'));
|
|
634
776
|
// The site's language and name, before anything else is written in them.
|
|
635
777
|
if (input.site?.locale && input.site.locale !== ctx.config().locales.default) {
|
|
636
778
|
const value = { default: input.site.locale, available: [input.site.locale] };
|
|
@@ -683,19 +825,19 @@ export function createApp(base) {
|
|
|
683
825
|
const until = await auth.lockedUntil(key);
|
|
684
826
|
if (until) {
|
|
685
827
|
const minutes = Math.max(1, Math.ceil((until.getTime() - Date.now()) / 60_000));
|
|
686
|
-
throw new ApiError(429, 'too_many_attempts',
|
|
828
|
+
throw new ApiError(429, 'too_many_attempts', t.n(minutes, 'Troppi tentativi: riprova tra un minuto', 'Troppi tentativi: riprova tra {n} minuti'));
|
|
687
829
|
}
|
|
688
830
|
}
|
|
689
831
|
};
|
|
690
832
|
/** A failed sign-in: counted for the email and the address; the owner hears when their account pauses. */
|
|
691
|
-
const signInFailed = async (email, ip, hasAccount) => {
|
|
833
|
+
const signInFailed = async (email, ip, hasAccount, locale) => {
|
|
692
834
|
const byEmail = await auth.recordFailure(`email:${email}`, EMAIL_THRESHOLD);
|
|
693
835
|
await auth.recordFailure(`ip:${ip}`, IP_THRESHOLD);
|
|
694
836
|
if (hasAccount && byEmail.failures === EMAIL_THRESHOLD)
|
|
695
|
-
await mailNotice(email, 'Tentativi di accesso non riusciti', [
|
|
696
|
-
|
|
697
|
-
'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.',
|
|
698
|
-
]);
|
|
837
|
+
await withLang(langFor(locale), () => mailNotice(email, t('Tentativi di accesso non riusciti'), [
|
|
838
|
+
t('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.', { n: EMAIL_THRESHOLD }),
|
|
839
|
+
t('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.'),
|
|
840
|
+
]));
|
|
699
841
|
};
|
|
700
842
|
/** Passwords accepted, codes still due: by ticket, in memory for a few minutes. */
|
|
701
843
|
const challenges = new Map();
|
|
@@ -731,7 +873,7 @@ export function createApp(base) {
|
|
|
731
873
|
// A password is checked even for an unknown email: the answer takes as long either way.
|
|
732
874
|
const ok = await verifyPassword(input.password, found?.passwordHash ?? (await dummyHash));
|
|
733
875
|
if (!found || !ok) {
|
|
734
|
-
await signInFailed(email, ip, !!found);
|
|
876
|
+
await signInFailed(email, ip, !!found, found?.locale);
|
|
735
877
|
await record(c, 'login.failed', email, { known: !!found }, found ? { id: found.id, name: found.name } : null);
|
|
736
878
|
throw new ApiError(401, 'invalid_credentials', 'Email or password is not correct');
|
|
737
879
|
}
|
|
@@ -754,23 +896,32 @@ export function createApp(base) {
|
|
|
754
896
|
await auth.recordFailure(`forgot:${email}`, FORGOT_THRESHOLD);
|
|
755
897
|
await auth.recordFailure(`forgot-ip:${ip}`, FORGOT_IP_THRESHOLD);
|
|
756
898
|
const mail = isConfigured(await settings.get('mail'));
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
899
|
+
const origin = strangerOrigin(c);
|
|
900
|
+
if (mail && !origin)
|
|
901
|
+
console.warn('Materialize: no password link sent, because the site address (MATERIALIZE_SITE_URL) is not set');
|
|
902
|
+
// After the answer: how long it takes must not tell whether the email has an account.
|
|
903
|
+
if (mail && origin)
|
|
904
|
+
void (async () => {
|
|
905
|
+
const found = await auth.findForLogin(email);
|
|
906
|
+
if (!found)
|
|
907
|
+
return;
|
|
908
|
+
const { token } = await auth.createInvite({
|
|
909
|
+
kind: 'reset',
|
|
910
|
+
email: found.email,
|
|
911
|
+
name: found.name,
|
|
912
|
+
role: found.role,
|
|
913
|
+
userId: found.id,
|
|
914
|
+
ttlMs: FORGOT_TTL,
|
|
915
|
+
});
|
|
916
|
+
await withLang(langFor(found.locale), () => mailLink(origin, found.email, t('Nuova password'), [
|
|
917
|
+
t('Qualcuno ha chiesto una nuova password per il tuo account su {site}.', {
|
|
918
|
+
site: ctx.config().site.name,
|
|
919
|
+
}),
|
|
920
|
+
t('Se non sei stato tu, ignora questa email: la password resta quella di prima.'),
|
|
921
|
+
t('Per sceglierne una nuova, apri questo link entro un’ora:'),
|
|
922
|
+
], linkPath(token)));
|
|
923
|
+
await record(c, 'password.forgot', found.email, null, { id: found.id, name: found.name });
|
|
924
|
+
})().catch((error) => console.error('Materialize: password link not sent', error));
|
|
774
925
|
return c.json({ ok: true, mail });
|
|
775
926
|
});
|
|
776
927
|
/** The second step: a code from the app, or a recovery code. */
|
|
@@ -827,7 +978,7 @@ export function createApp(base) {
|
|
|
827
978
|
const hash = await auth.getPasswordHash(userId);
|
|
828
979
|
if (!hash || !(await verifyPassword(password, hash)))
|
|
829
980
|
throw new ApiError(422, 'wrong_password', 'The password is not correct', [
|
|
830
|
-
{ path, message: 'La password non è corretta' },
|
|
981
|
+
{ path, message: t('La password non è corretta') },
|
|
831
982
|
]);
|
|
832
983
|
};
|
|
833
984
|
app.get('/api/auth/security', requireUser, requireSession, async (c) => {
|
|
@@ -859,7 +1010,7 @@ export function createApp(base) {
|
|
|
859
1010
|
const step = verifyCode(state.pending, input.code);
|
|
860
1011
|
if (step === null)
|
|
861
1012
|
throw new ApiError(422, 'invalid_code', 'The code is not correct', [
|
|
862
|
-
{ path: 'code', message: 'Il codice non è corretto' },
|
|
1013
|
+
{ path: 'code', message: t('Il codice non è corretto') },
|
|
863
1014
|
]);
|
|
864
1015
|
const codes = recoveryCodes();
|
|
865
1016
|
await auth.enableTwoFactor(user.id, step, codes);
|
|
@@ -913,7 +1064,12 @@ export function createApp(base) {
|
|
|
913
1064
|
before: before || undefined,
|
|
914
1065
|
limit: 50,
|
|
915
1066
|
});
|
|
916
|
-
|
|
1067
|
+
// Messages copied into the log (a release or a schedule that failed) are kept texts.
|
|
1068
|
+
return c.json({
|
|
1069
|
+
items: items.map((i) => typeof i.detail?.message === 'string'
|
|
1070
|
+
? { ...i, detail: { ...i.detail, message: t.read(i.detail.message) } }
|
|
1071
|
+
: i),
|
|
1072
|
+
});
|
|
917
1073
|
});
|
|
918
1074
|
/**
|
|
919
1075
|
* What an administrator should look at, in order: who signs in without a second step, whether
|
|
@@ -921,6 +1077,7 @@ export function createApp(base) {
|
|
|
921
1077
|
*/
|
|
922
1078
|
app.get('/api/security/overview', requireUser, requireAdmin, async (c) => {
|
|
923
1079
|
const checks = [];
|
|
1080
|
+
const hoursAgo = (ms) => new Intl.RelativeTimeFormat(intlLocaleOf(currentLang()), { numeric: 'auto' }).format(-Math.round(ms / 3_600_000), 'hour');
|
|
924
1081
|
const people = await auth.listUsers();
|
|
925
1082
|
const required = await twoFactorRequired();
|
|
926
1083
|
const admins = people.filter((p) => p.role === 'admin');
|
|
@@ -930,36 +1087,34 @@ export function createApp(base) {
|
|
|
930
1087
|
? {
|
|
931
1088
|
id: 'admins-2fa',
|
|
932
1089
|
status: 'bad',
|
|
933
|
-
title: adminsWithout.length
|
|
934
|
-
|
|
935
|
-
: `${adminsWithout.length} amministratori entrano con la sola password`,
|
|
936
|
-
detail: `${adminsWithout.map((p) => p.name).join(', ')}: chi ruba quella password controlla tutto il sito. Attivate la verifica in due passaggi dal profilo → Sicurezza.`,
|
|
1090
|
+
title: t.n(adminsWithout.length, 'Un amministratore entra con la sola password', '{n} amministratori entrano con la sola password'),
|
|
1091
|
+
detail: t('{names}: chi ruba quella password controlla tutto il sito. Attiva la verifica in due passaggi dal profilo → Sicurezza.', { names: adminsWithout.map((p) => p.name).join(', ') }),
|
|
937
1092
|
action: adminsWithout.some((p) => p.id === c.get('user').id) ? 'profile' : 'people',
|
|
938
1093
|
}
|
|
939
1094
|
: {
|
|
940
1095
|
id: 'admins-2fa',
|
|
941
1096
|
status: 'ok',
|
|
942
|
-
title: 'Gli amministratori usano la verifica in due passaggi',
|
|
943
|
-
detail: 'Per entrare servono password e codice dal telefono.',
|
|
1097
|
+
title: t('Gli amministratori usano la verifica in due passaggi'),
|
|
1098
|
+
detail: t('Per entrare servono password e codice dal telefono.'),
|
|
944
1099
|
});
|
|
945
1100
|
checks.push(required
|
|
946
1101
|
? {
|
|
947
1102
|
id: 'everyone-2fa',
|
|
948
1103
|
status: 'ok',
|
|
949
|
-
title: 'Verifica in due passaggi richiesta a tutti',
|
|
1104
|
+
title: t('Verifica in due passaggi richiesta a tutti'),
|
|
950
1105
|
detail: without.length
|
|
951
|
-
?
|
|
952
|
-
: 'Tutti l’hanno attivata.',
|
|
1106
|
+
? t.n(without.length, 'Una persona la attiverà al prossimo accesso.', '{n} persone la attiveranno al prossimo accesso.')
|
|
1107
|
+
: t('Tutti l’hanno attivata.'),
|
|
953
1108
|
}
|
|
954
1109
|
: {
|
|
955
1110
|
id: 'everyone-2fa',
|
|
956
1111
|
status: without.length ? 'warn' : 'ok',
|
|
957
1112
|
title: without.length
|
|
958
|
-
?
|
|
959
|
-
: 'Tutti usano la verifica in due passaggi',
|
|
1113
|
+
? t.n(without.length, 'Una persona entra con la sola password', '{n} persone entrano con la sola password')
|
|
1114
|
+
: t('Tutti usano la verifica in due passaggi'),
|
|
960
1115
|
detail: without.length
|
|
961
|
-
? '
|
|
962
|
-
: '
|
|
1116
|
+
? t('Puoi richiederla a tutti da Persone: chi non l’ha la attiverà al prossimo accesso.')
|
|
1117
|
+
: t('Puoi anche richiederla a tutti da Persone, per chi entrerà in futuro.'),
|
|
963
1118
|
action: without.length ? 'people' : undefined,
|
|
964
1119
|
});
|
|
965
1120
|
const mail = isConfigured(await settings.get('mail'));
|
|
@@ -967,14 +1122,14 @@ export function createApp(base) {
|
|
|
967
1122
|
? {
|
|
968
1123
|
id: 'mail',
|
|
969
1124
|
status: 'ok',
|
|
970
|
-
title: 'Gli avvisi arrivano per email',
|
|
971
|
-
detail: 'Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.',
|
|
1125
|
+
title: t('Gli avvisi arrivano per email'),
|
|
1126
|
+
detail: t('Tentativi di accesso falliti, cambi di indirizzo e di verifica vengono segnalati a chi riguardano.'),
|
|
972
1127
|
}
|
|
973
1128
|
: {
|
|
974
1129
|
id: 'mail',
|
|
975
1130
|
status: 'warn',
|
|
976
|
-
title: 'L’invio delle email non è configurato',
|
|
977
|
-
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.',
|
|
1131
|
+
title: t('L’invio delle email non è configurato'),
|
|
1132
|
+
detail: t('Nessuno viene avvisato di tentativi di accesso falliti o di un indirizzo cambiato, e i link per una nuova password vanno mandati a mano.'),
|
|
978
1133
|
action: 'mail',
|
|
979
1134
|
});
|
|
980
1135
|
const url = new URL(c.req.url);
|
|
@@ -986,92 +1141,108 @@ export function createApp(base) {
|
|
|
986
1141
|
? {
|
|
987
1142
|
id: 'https',
|
|
988
1143
|
status: 'info',
|
|
989
|
-
title: 'Pannello aperto in locale',
|
|
990
|
-
detail: 'Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.',
|
|
1144
|
+
title: t('Pannello aperto in locale'),
|
|
1145
|
+
detail: t('Online, il sito va servito in HTTPS: password e sessioni viaggiano cifrate.'),
|
|
991
1146
|
}
|
|
992
1147
|
: secure
|
|
993
1148
|
? {
|
|
994
1149
|
id: 'https',
|
|
995
1150
|
status: 'ok',
|
|
996
|
-
title: 'Connessione cifrata (HTTPS)',
|
|
997
|
-
detail: 'Password e sessioni viaggiano cifrate.',
|
|
1151
|
+
title: t('Connessione cifrata (HTTPS)'),
|
|
1152
|
+
detail: t('Password e sessioni viaggiano cifrate.'),
|
|
998
1153
|
}
|
|
999
1154
|
: {
|
|
1000
1155
|
id: 'https',
|
|
1001
1156
|
status: 'bad',
|
|
1002
|
-
title: 'Il pannello non usa HTTPS',
|
|
1003
|
-
detail: 'Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.',
|
|
1157
|
+
title: t('Il pannello non usa HTTPS'),
|
|
1158
|
+
detail: t('Password e sessioni viaggiano in chiaro: attiva HTTPS sull’hosting (Let’s Encrypt è gratuito) o metti il sito dietro Cloudflare.'),
|
|
1004
1159
|
});
|
|
1160
|
+
// Without it, links in emails asked for from the sign-in page cannot be trusted, so none is sent.
|
|
1161
|
+
if (!siteOrigin() && !local)
|
|
1162
|
+
checks.push({
|
|
1163
|
+
id: 'site-url',
|
|
1164
|
+
status: 'warn',
|
|
1165
|
+
title: t('L’indirizzo del sito non è impostato'),
|
|
1166
|
+
detail: t('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.', { env: 'MATERIALIZE_SITE_URL' }),
|
|
1167
|
+
});
|
|
1005
1168
|
const forwarded = !!c.req.header('x-forwarded-for');
|
|
1006
1169
|
const trust = ctx.config().server.trustProxy;
|
|
1007
1170
|
if (forwarded && !trust)
|
|
1008
1171
|
checks.push({
|
|
1009
1172
|
id: 'proxy',
|
|
1010
1173
|
status: 'warn',
|
|
1011
|
-
title: 'Il sito è dietro un proxy, ma non lo sa',
|
|
1012
|
-
detail: 'Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: {
|
|
1174
|
+
title: t('Il sito è dietro un proxy, ma non lo sa'),
|
|
1175
|
+
detail: t('Tutte le richieste sembrano arrivare dallo stesso indirizzo, quindi i limiti agli accessi per indirizzo non funzionano. Imposta server: {code} in materialize.config.ts.', { code: '{ trustProxy: true }' }),
|
|
1013
1176
|
});
|
|
1014
1177
|
else if (trust && !forwarded && !local)
|
|
1015
1178
|
checks.push({
|
|
1016
1179
|
id: 'proxy',
|
|
1017
1180
|
status: 'warn',
|
|
1018
|
-
title: 'trustProxy è acceso, ma non arriva nessun proxy',
|
|
1019
|
-
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.',
|
|
1181
|
+
title: t('trustProxy è acceso, ma non arriva nessun proxy'),
|
|
1182
|
+
detail: t('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.'),
|
|
1020
1183
|
});
|
|
1021
1184
|
const day = new Date(Date.now() - 24 * 60 * 60_000);
|
|
1022
1185
|
const failed = (await activityLog.countSince('login.failed', day)) + (await activityLog.countSince('login.code_failed', day));
|
|
1023
1186
|
const locked = await activityLog.lockedKeys();
|
|
1024
1187
|
checks.push({
|
|
1025
1188
|
id: 'failed',
|
|
1026
|
-
|
|
1189
|
+
// Some failures are people mistyping; many, or a paused account, are someone trying.
|
|
1190
|
+
status: failed >= 20 || locked.length ? 'warn' : failed ? 'info' : 'ok',
|
|
1027
1191
|
title: failed
|
|
1028
|
-
?
|
|
1029
|
-
: 'Nessun accesso fallito nelle ultime 24 ore',
|
|
1192
|
+
? t.n(failed, 'Un accesso fallito nelle ultime 24 ore', '{n} accessi falliti nelle ultime 24 ore')
|
|
1193
|
+
: t('Nessun accesso fallito nelle ultime 24 ore'),
|
|
1030
1194
|
detail: locked.length
|
|
1031
|
-
?
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1195
|
+
? t('In pausa adesso: {list}. Le pause finiscono da sole.', {
|
|
1196
|
+
list: locked
|
|
1197
|
+
.map((k) => k.startsWith('ip:')
|
|
1198
|
+
? t('indirizzo {ip}', { ip: k.slice(3) })
|
|
1199
|
+
: k.startsWith('2fa:')
|
|
1200
|
+
? t('un codice di verifica')
|
|
1201
|
+
: k.replace(/^email:/, ''))
|
|
1202
|
+
.join(', '),
|
|
1203
|
+
})
|
|
1204
|
+
: t('Dopo 5 errori un’email va in pausa, dopo 20 un indirizzo: la pausa raddoppia a ogni nuovo errore.'),
|
|
1038
1205
|
});
|
|
1039
1206
|
if (!backups.available())
|
|
1040
1207
|
checks.push({
|
|
1041
1208
|
id: 'backups',
|
|
1042
1209
|
status: 'info',
|
|
1043
|
-
title: 'Database ospitato da un fornitore',
|
|
1044
|
-
detail: 'I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.',
|
|
1210
|
+
title: t('Database ospitato da un fornitore'),
|
|
1211
|
+
detail: t('I backup del database li fa il fornitore (Turso, libSQL): controlla che siano attivi nel suo pannello.'),
|
|
1045
1212
|
});
|
|
1046
1213
|
else {
|
|
1047
1214
|
const b = await backupSettings();
|
|
1048
1215
|
const last = (await backups.list())[0];
|
|
1049
1216
|
const age = last ? Date.now() - last.createdAt.getTime() : Number.POSITIVE_INFINITY;
|
|
1217
|
+
const days = Math.round(age / 86_400_000);
|
|
1050
1218
|
checks.push(!b.enabled
|
|
1051
1219
|
? {
|
|
1052
1220
|
id: 'backups',
|
|
1053
1221
|
status: 'warn',
|
|
1054
|
-
title: 'Backup automatici spenti',
|
|
1222
|
+
title: t('Backup automatici spenti'),
|
|
1055
1223
|
detail: last
|
|
1056
|
-
?
|
|
1057
|
-
: 'Non c’è nessuna copia del database. Riaccendili qui sotto.',
|
|
1224
|
+
? t.n(days, 'L’ultima copia è di un giorno fa. Riaccendili qui sotto.', 'L’ultima copia è di {n} giorni fa. Riaccendili qui sotto.')
|
|
1225
|
+
: t('Non c’è nessuna copia del database. Riaccendili qui sotto.'),
|
|
1058
1226
|
action: 'backups',
|
|
1059
1227
|
}
|
|
1060
1228
|
: age > 2 * 86_400_000
|
|
1061
1229
|
? {
|
|
1062
1230
|
id: 'backups',
|
|
1063
1231
|
status: last ? 'warn' : 'info',
|
|
1064
|
-
title: last ? 'L’ultimo backup è vecchio' : 'Il primo backup arriva a breve',
|
|
1232
|
+
title: last ? t('L’ultimo backup è vecchio') : t('Il primo backup arriva a breve'),
|
|
1065
1233
|
detail: last
|
|
1066
|
-
? 'Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.'
|
|
1067
|
-
: 'Il server fa una copia del database poco dopo l’avvio, poi una al giorno.',
|
|
1234
|
+
? t('Il server fa una copia al giorno mentre è acceso: se resta spento a lungo, scaricane una a mano.')
|
|
1235
|
+
: t('Il server fa una copia del database poco dopo l’avvio, poi una al giorno.'),
|
|
1068
1236
|
action: 'backups',
|
|
1069
1237
|
}
|
|
1070
1238
|
: {
|
|
1071
1239
|
id: 'backups',
|
|
1072
1240
|
status: 'ok',
|
|
1073
|
-
title: 'Backup del database ogni giorno',
|
|
1074
|
-
detail:
|
|
1241
|
+
title: t('Backup del database ogni giorno'),
|
|
1242
|
+
detail: t('Ultima copia {when}, immagini comprese; ne restano {keep}.', {
|
|
1243
|
+
when: hoursAgo(age),
|
|
1244
|
+
keep: b.keep,
|
|
1245
|
+
}),
|
|
1075
1246
|
});
|
|
1076
1247
|
}
|
|
1077
1248
|
{
|
|
@@ -1081,25 +1252,28 @@ export function createApp(base) {
|
|
|
1081
1252
|
? {
|
|
1082
1253
|
id: 'offsite',
|
|
1083
1254
|
status: 'warn',
|
|
1084
|
-
title: 'I backup restano sul server',
|
|
1085
|
-
detail: 'Se il server si rompe, si perdono anche le copie. Attiva la copia fuori dal server (S3 o WebDAV), oppure scarica ogni tanto l’archivio completo.',
|
|
1255
|
+
title: t('I backup restano sul server'),
|
|
1256
|
+
detail: t('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.'),
|
|
1086
1257
|
action: 'backups',
|
|
1087
1258
|
}
|
|
1088
1259
|
: status && !status.ok
|
|
1089
1260
|
? {
|
|
1090
1261
|
id: 'offsite',
|
|
1091
1262
|
status: 'bad',
|
|
1092
|
-
title: 'La copia fuori dal server non riesce',
|
|
1093
|
-
detail: status.message ?? 'Controlla il collegamento.',
|
|
1263
|
+
title: t('La copia fuori dal server non riesce'),
|
|
1264
|
+
detail: status.message ?? t('Controlla il collegamento.'),
|
|
1094
1265
|
action: 'backups',
|
|
1095
1266
|
}
|
|
1096
1267
|
: {
|
|
1097
1268
|
id: 'offsite',
|
|
1098
1269
|
status: 'ok',
|
|
1099
|
-
title: 'Copia cifrata fuori dal server',
|
|
1270
|
+
title: t('Copia cifrata fuori dal server'),
|
|
1100
1271
|
detail: status
|
|
1101
|
-
?
|
|
1102
|
-
|
|
1272
|
+
? t('Ultima copia {when}, su {where}.', {
|
|
1273
|
+
when: hoursAgo(Date.now() - new Date(status.at).getTime()),
|
|
1274
|
+
where: o.kind === 's3' ? 'S3' : 'WebDAV',
|
|
1275
|
+
})
|
|
1276
|
+
: t('La prima copia parte entro un’ora.'),
|
|
1103
1277
|
});
|
|
1104
1278
|
}
|
|
1105
1279
|
const tokens = await activityLog.tokens();
|
|
@@ -1108,10 +1282,10 @@ export function createApp(base) {
|
|
|
1108
1282
|
checks.push({
|
|
1109
1283
|
id: 'tokens',
|
|
1110
1284
|
status: stale.length ? 'warn' : 'ok',
|
|
1111
|
-
title:
|
|
1285
|
+
title: t.n(tokens.length, 'Un token di accesso per i client AI', '{n} token di accesso per i client AI'),
|
|
1112
1286
|
detail: stale.length
|
|
1113
|
-
?
|
|
1114
|
-
: 'Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.',
|
|
1287
|
+
? t.n(stale.length, 'Uno non è usato da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.', '{n} non sono usati da più di 90 giorni: chi li ha creati può revocarli da Impostazioni → AI e MCP.')
|
|
1288
|
+
: t('Tutti usati di recente. Ognuno può revocare i propri da Impostazioni → AI e MCP.'),
|
|
1115
1289
|
});
|
|
1116
1290
|
return c.json({ checks });
|
|
1117
1291
|
});
|
|
@@ -1200,21 +1374,30 @@ export function createApp(base) {
|
|
|
1200
1374
|
s3: s3Input.optional(),
|
|
1201
1375
|
webdav: webdavInput.optional(),
|
|
1202
1376
|
});
|
|
1203
|
-
/**
|
|
1377
|
+
/**
|
|
1378
|
+
* The settings as sent, with the saved secrets where the panel left them empty — only for the
|
|
1379
|
+
* same server: a secret is never sent to an address it was not given for (a stolen session
|
|
1380
|
+
* could otherwise point the saved key to its own server and read it there).
|
|
1381
|
+
*/
|
|
1204
1382
|
const withSecrets = (input, saved) => {
|
|
1205
1383
|
if (input.kind === 's3' && !input.s3)
|
|
1206
1384
|
throw new ApiError(400, 'missing_s3', 'S3 settings are missing');
|
|
1207
1385
|
if (input.kind === 'webdav' && !input.webdav)
|
|
1208
1386
|
throw new ApiError(400, 'missing_webdav', 'WebDAV settings are missing');
|
|
1387
|
+
const sameS3 = !!input.s3 && sameServer(input.s3.endpoint, saved.s3?.endpoint);
|
|
1388
|
+
const sameDav = !!input.webdav && sameServer(input.webdav.url, saved.webdav?.url);
|
|
1209
1389
|
return {
|
|
1210
1390
|
kind: input.kind,
|
|
1211
1391
|
keep: input.keep,
|
|
1212
1392
|
key: saved.key,
|
|
1213
1393
|
s3: input.s3
|
|
1214
|
-
? {
|
|
1394
|
+
? {
|
|
1395
|
+
...input.s3,
|
|
1396
|
+
secretAccessKey: input.s3.secretAccessKey || (sameS3 ? saved.s3?.secretAccessKey : '') || '',
|
|
1397
|
+
}
|
|
1215
1398
|
: saved.s3,
|
|
1216
1399
|
webdav: input.webdav
|
|
1217
|
-
? { ...input.webdav, password: input.webdav.password || saved.webdav?.password || '' }
|
|
1400
|
+
? { ...input.webdav, password: input.webdav.password || (sameDav ? saved.webdav?.password : '') || '' }
|
|
1218
1401
|
: saved.webdav,
|
|
1219
1402
|
};
|
|
1220
1403
|
};
|
|
@@ -1295,7 +1478,7 @@ export function createApp(base) {
|
|
|
1295
1478
|
latest: null,
|
|
1296
1479
|
notes: [],
|
|
1297
1480
|
checkedAt: null,
|
|
1298
|
-
unavailable: 'Questo server non è avviato da materialize start: aggiorna da terminale.',
|
|
1481
|
+
unavailable: t('Questo server non è avviato da materialize start: aggiorna da terminale.'),
|
|
1299
1482
|
previous: null,
|
|
1300
1483
|
job: { state: 'idle' },
|
|
1301
1484
|
last: null,
|
|
@@ -1310,7 +1493,7 @@ export function createApp(base) {
|
|
|
1310
1493
|
app.post('/api/updates/install', requireUser, requireSession, requireAdmin, async (c) => {
|
|
1311
1494
|
const input = await body(c, z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }));
|
|
1312
1495
|
if (!ctx.updates)
|
|
1313
|
-
throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
|
|
1496
|
+
throw new ApiError(409, 'updates_unavailable', t('Gli aggiornamenti dal pannello non sono disponibili qui'));
|
|
1314
1497
|
try {
|
|
1315
1498
|
await ctx.updates.install(input.version, backupBeforeUpdate);
|
|
1316
1499
|
}
|
|
@@ -1322,7 +1505,7 @@ export function createApp(base) {
|
|
|
1322
1505
|
});
|
|
1323
1506
|
app.post('/api/updates/rollback', requireUser, requireSession, requireAdmin, async (c) => {
|
|
1324
1507
|
if (!ctx.updates)
|
|
1325
|
-
throw new ApiError(409, 'updates_unavailable', 'Gli aggiornamenti dal pannello non sono disponibili qui');
|
|
1508
|
+
throw new ApiError(409, 'updates_unavailable', t('Gli aggiornamenti dal pannello non sono disponibili qui'));
|
|
1326
1509
|
try {
|
|
1327
1510
|
await ctx.updates.rollback();
|
|
1328
1511
|
}
|
|
@@ -1369,11 +1552,17 @@ export function createApp(base) {
|
|
|
1369
1552
|
return c.json({ user: await auth.updateUser(c.get('user').id, input) });
|
|
1370
1553
|
});
|
|
1371
1554
|
/** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
|
|
1372
|
-
const emailChanged = async (before, after, by) => {
|
|
1373
|
-
await mailNotice(before, 'Il tuo indirizzo email è cambiato', [
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1555
|
+
const emailChanged = async (before, after, by, locale) => {
|
|
1556
|
+
await withLang(langFor(locale), () => mailNotice(before, t('Il tuo indirizzo email è cambiato'), [
|
|
1557
|
+
by === 'self'
|
|
1558
|
+
? t('Hai cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.', { before, after })
|
|
1559
|
+
: t('{by} ha cambiato l’indirizzo con cui entri nel pannello del sito: da {before} a {after}.', {
|
|
1560
|
+
by,
|
|
1561
|
+
before,
|
|
1562
|
+
after,
|
|
1563
|
+
}),
|
|
1564
|
+
t('Se non te lo aspettavi, rispondi a questa email o avvisa un amministratore del sito.'),
|
|
1565
|
+
]));
|
|
1377
1566
|
};
|
|
1378
1567
|
// Changing one's own email needs the password: a session left open is not enough.
|
|
1379
1568
|
app.post('/api/auth/email', requireUser, requireSession, async (c) => {
|
|
@@ -1382,13 +1571,13 @@ export function createApp(base) {
|
|
|
1382
1571
|
const hash = await auth.getPasswordHash(user.id);
|
|
1383
1572
|
if (!hash || !(await verifyPassword(input.password, hash))) {
|
|
1384
1573
|
throw new ApiError(422, 'wrong_password', 'The password is not correct', [
|
|
1385
|
-
{ path: 'password', message: 'La password non è corretta' },
|
|
1574
|
+
{ path: 'password', message: t('La password non è corretta') },
|
|
1386
1575
|
]);
|
|
1387
1576
|
}
|
|
1388
1577
|
try {
|
|
1389
1578
|
const updated = await auth.updateUser(user.id, { email: input.email });
|
|
1390
1579
|
if (updated && updated.email !== user.email) {
|
|
1391
|
-
await emailChanged(user.email, updated.email, 'self');
|
|
1580
|
+
await emailChanged(user.email, updated.email, 'self', user.locale);
|
|
1392
1581
|
await record(c, 'user.email', updated.email, { from: user.email });
|
|
1393
1582
|
}
|
|
1394
1583
|
return c.json({ user: updated });
|
|
@@ -1400,12 +1589,12 @@ export function createApp(base) {
|
|
|
1400
1589
|
}
|
|
1401
1590
|
});
|
|
1402
1591
|
app.post('/api/auth/password', requireUser, requireSession, async (c) => {
|
|
1403
|
-
const input = await body(c, z.object({ current: z.string().min(1), next: z.string().min(10) }));
|
|
1592
|
+
const input = await body(c, z.object({ current: z.string().min(1).max(MAX_PASSWORD), next: z.string().min(10).max(MAX_PASSWORD) }));
|
|
1404
1593
|
const user = c.get('user');
|
|
1405
1594
|
const hash = await auth.getPasswordHash(user.id);
|
|
1406
1595
|
if (!hash || !(await verifyPassword(input.current, hash))) {
|
|
1407
1596
|
throw new ApiError(422, 'wrong_password', 'The current password is not correct', [
|
|
1408
|
-
{ path: 'current', message: 'La password attuale non è corretta' },
|
|
1597
|
+
{ path: 'current', message: t('La password attuale non è corretta') },
|
|
1409
1598
|
]);
|
|
1410
1599
|
}
|
|
1411
1600
|
// Other devices are signed out; this one stays in.
|
|
@@ -1431,9 +1620,9 @@ export function createApp(base) {
|
|
|
1431
1620
|
ttlMs: INVITE_TTL,
|
|
1432
1621
|
});
|
|
1433
1622
|
const link = linkPath(token);
|
|
1434
|
-
const mailed = await mailLink(c, input.email, 'Il tuo invito', [
|
|
1435
|
-
|
|
1436
|
-
'Apri questo link per scegliere la tua password ed entrare:',
|
|
1623
|
+
const mailed = await mailLink(publicOrigin(c), input.email, t('Il tuo invito'), [
|
|
1624
|
+
t('{name} ti ha invitato a lavorare sul sito con Materialize.', { name: c.get('user').name }),
|
|
1625
|
+
t('Apri questo link per scegliere la tua password ed entrare:'),
|
|
1437
1626
|
], link);
|
|
1438
1627
|
await record(c, 'user.invited', input.email, { role: input.role });
|
|
1439
1628
|
return c.json({ invite, link, mailed }, 201);
|
|
@@ -1463,7 +1652,7 @@ export function createApp(base) {
|
|
|
1463
1652
|
try {
|
|
1464
1653
|
const updated = await auth.updateUser(target.id, input);
|
|
1465
1654
|
if (updated && updated.email !== target.email) {
|
|
1466
|
-
await emailChanged(target.email, updated.email, c.get('user').name);
|
|
1655
|
+
await emailChanged(target.email, updated.email, c.get('user').name, target.locale);
|
|
1467
1656
|
await record(c, 'user.email', updated.email, { from: target.email });
|
|
1468
1657
|
}
|
|
1469
1658
|
if (updated && updated.role !== target.role)
|
|
@@ -1491,10 +1680,10 @@ export function createApp(base) {
|
|
|
1491
1680
|
app.delete('/api/users/:id/two-factor', requireUser, requireSession, requireAdmin, async (c) => {
|
|
1492
1681
|
const target = await loadUser(c.req.param('id'));
|
|
1493
1682
|
await auth.disableTwoFactor(target.id);
|
|
1494
|
-
await mailNotice(target.email, 'Verifica in due passaggi disattivata', [
|
|
1495
|
-
|
|
1496
|
-
'Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.',
|
|
1497
|
-
]);
|
|
1683
|
+
await withLang(langFor(target.locale), () => mailNotice(target.email, t('Verifica in due passaggi disattivata'), [
|
|
1684
|
+
t('{name} ha disattivato la verifica in due passaggi del tuo account: per entrare nel pannello ora basta la password.', { name: c.get('user').name }),
|
|
1685
|
+
t('Riattivala dal tuo profilo appena puoi. Se non te lo aspettavi, avvisa un amministratore.'),
|
|
1686
|
+
]));
|
|
1498
1687
|
await record(c, 'twofactor.reset', target.email);
|
|
1499
1688
|
return c.json({ user: await auth.getUser(target.id) });
|
|
1500
1689
|
});
|
|
@@ -1510,7 +1699,7 @@ export function createApp(base) {
|
|
|
1510
1699
|
ttlMs: RESET_TTL,
|
|
1511
1700
|
});
|
|
1512
1701
|
const link = linkPath(token);
|
|
1513
|
-
const mailed = await mailLink(c, target.email, 'Nuova password', ['È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:'], link);
|
|
1702
|
+
const mailed = await withLang(langFor(target.locale), () => mailLink(publicOrigin(c), target.email, t('Nuova password'), [t('È stata chiesta una nuova password per il tuo account. Il link vale 24 ore:')], link));
|
|
1514
1703
|
await record(c, 'user.reset_link', target.email);
|
|
1515
1704
|
return c.json({ invite, link, mailed }, 201);
|
|
1516
1705
|
});
|
|
@@ -1528,11 +1717,14 @@ export function createApp(base) {
|
|
|
1528
1717
|
app.post('/api/invites/:token/accept', async (c) => {
|
|
1529
1718
|
const token = c.req.param('token');
|
|
1530
1719
|
const invite = await loadInvite(token);
|
|
1531
|
-
const input = await body(c, z.object({ name: z.string().trim().min(1).max(80).optional(), password: z.string().min(10) }));
|
|
1720
|
+
const input = await body(c, z.object({ name: z.string().trim().min(1).max(80).optional(), password: z.string().min(10).max(MAX_PASSWORD) }));
|
|
1532
1721
|
const passwordHash = await hashPassword(input.password);
|
|
1533
1722
|
let user;
|
|
1534
1723
|
if (invite.kind === 'reset' && invite.userId) {
|
|
1724
|
+
// Signed out everywhere (setPassword) and its tokens revoked: a reset is how an account
|
|
1725
|
+
// someone else got into is won back, and what they made with it must stop working too.
|
|
1535
1726
|
await auth.setPassword(invite.userId, passwordHash);
|
|
1727
|
+
await auth.revokeAllTokens(invite.userId);
|
|
1536
1728
|
user = await auth.getUser(invite.userId);
|
|
1537
1729
|
}
|
|
1538
1730
|
else {
|
|
@@ -1639,7 +1831,9 @@ export function createApp(base) {
|
|
|
1639
1831
|
const usage = await globalUsage();
|
|
1640
1832
|
const inUse = removed.find((id) => usage[id]?.length);
|
|
1641
1833
|
if (inUse)
|
|
1642
|
-
throw new ApiError(409, 'global_in_use',
|
|
1834
|
+
throw new ApiError(409, 'global_in_use', t('La sezione è ancora in {pages}: toglila prima dalle pagine', {
|
|
1835
|
+
pages: usage[inUse]?.map((title) => `“${title}”`).join(', '),
|
|
1836
|
+
}));
|
|
1643
1837
|
}
|
|
1644
1838
|
const ids = [...new Set([...Object.keys(before), ...Object.keys(input.globals)])].sort();
|
|
1645
1839
|
await guardDesign(c.get('user'), ids.map((id) => before[id] ?? { id, type: 'frame' }), ids.map((id) => input.globals[id] ?? { id, type: 'frame' }));
|
|
@@ -1676,6 +1870,69 @@ export function createApp(base) {
|
|
|
1676
1870
|
await comments.remove(comment.id);
|
|
1677
1871
|
return c.body(null, 204);
|
|
1678
1872
|
});
|
|
1873
|
+
const turns = new Map();
|
|
1874
|
+
const TURN_TTL = 45_000;
|
|
1875
|
+
const turnOf = (docId) => {
|
|
1876
|
+
const turn = turns.get(docId);
|
|
1877
|
+
if (turn && Date.now() - turn.at > TURN_TTL) {
|
|
1878
|
+
turns.delete(docId);
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
return turn ?? null;
|
|
1882
|
+
};
|
|
1883
|
+
const turnJson = (turn, user, tab) => ({
|
|
1884
|
+
mine: !!turn && turn.userId === user.id && turn.tab === tab,
|
|
1885
|
+
holder: turn ? { name: turn.name, since: new Date(turn.since).toISOString(), self: turn.userId === user.id } : null,
|
|
1886
|
+
});
|
|
1887
|
+
/** Holds the turn (or asks for it); take: true takes it from whoever has it. */
|
|
1888
|
+
app.post('/api/documents/:id/turn', requireUser, async (c) => {
|
|
1889
|
+
const input = await body(c, z.object({ tab: z.string().min(8).max(64), take: z.boolean().optional() }));
|
|
1890
|
+
const user = c.get('user');
|
|
1891
|
+
const docId = c.req.param('id');
|
|
1892
|
+
const now = Date.now();
|
|
1893
|
+
for (const [k, t] of turns)
|
|
1894
|
+
if (now - t.at > TURN_TTL * 4)
|
|
1895
|
+
turns.delete(k);
|
|
1896
|
+
const turn = turnOf(docId);
|
|
1897
|
+
if (!turn || (turn.userId === user.id && turn.tab === input.tab) || input.take) {
|
|
1898
|
+
if (input.take && turn && turn.tab !== input.tab) {
|
|
1899
|
+
// The one who had it learns who took it, at their next beat.
|
|
1900
|
+
turns.set(`lost:${docId}:${turn.tab}`, { ...turn, takenBy: user.name, at: now });
|
|
1901
|
+
const doc = await content.get(docId);
|
|
1902
|
+
await record(c, 'document.turn_taken', doc?.title ?? '', { id: docId, from: turn.name });
|
|
1903
|
+
}
|
|
1904
|
+
const keep = turn && turn.userId === user.id && turn.tab === input.tab;
|
|
1905
|
+
turns.set(docId, { userId: user.id, name: user.name, tab: input.tab, since: keep ? turn.since : now, at: now });
|
|
1906
|
+
return c.json(turnJson(turnOf(docId), user, input.tab));
|
|
1907
|
+
}
|
|
1908
|
+
const taken = turns.get(`lost:${docId}:${input.tab}`);
|
|
1909
|
+
if (taken)
|
|
1910
|
+
turns.delete(`lost:${docId}:${input.tab}`);
|
|
1911
|
+
return c.json({ ...turnJson(turn, user, input.tab), takenBy: taken?.takenBy ?? null });
|
|
1912
|
+
});
|
|
1913
|
+
/** Leaves the turn: the document closed. */
|
|
1914
|
+
app.delete('/api/documents/:id/turn', requireUser, async (c) => {
|
|
1915
|
+
const docId = c.req.param('id');
|
|
1916
|
+
const turn = turnOf(docId);
|
|
1917
|
+
if (turn && turn.userId === c.get('user').id && turn.tab === c.req.query('tab'))
|
|
1918
|
+
turns.delete(docId);
|
|
1919
|
+
return c.body(null, 204);
|
|
1920
|
+
});
|
|
1921
|
+
/**
|
|
1922
|
+
* Changes to a document from someone who is not on turn are refused: from another person, or
|
|
1923
|
+
* from another tab of the same person. The API and AI tools of the person on turn pass.
|
|
1924
|
+
*/
|
|
1925
|
+
const guardTurn = (c, docId) => {
|
|
1926
|
+
const turn = turnOf(docId);
|
|
1927
|
+
if (!turn)
|
|
1928
|
+
return;
|
|
1929
|
+
const user = c.get('user');
|
|
1930
|
+
const tab = c.req.header('x-mz-tab');
|
|
1931
|
+
if (turn.userId !== user.id)
|
|
1932
|
+
throw new ApiError(409, 'not_your_turn', t('{name} sta modificando questo documento: le tue modifiche non sono state salvate', { name: turn.name }));
|
|
1933
|
+
if (tab && tab !== turn.tab)
|
|
1934
|
+
throw new ApiError(409, 'not_your_turn', t('Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate'));
|
|
1935
|
+
};
|
|
1679
1936
|
app.post('/api/documents/:id/presence', requireUser, async (c) => {
|
|
1680
1937
|
const input = await body(c, z.object({ selected: z.string().max(200).nullable() }));
|
|
1681
1938
|
const user = c.get('user');
|
|
@@ -1705,26 +1962,37 @@ export function createApp(base) {
|
|
|
1705
1962
|
return false;
|
|
1706
1963
|
}
|
|
1707
1964
|
};
|
|
1708
|
-
/**
|
|
1709
|
-
|
|
1710
|
-
* is known. The request's Host header is used only without it, as in local development: anyone
|
|
1711
|
-
* can send a request with a false Host, and a link to their server would carry the token there.
|
|
1712
|
-
*/
|
|
1713
|
-
const publicOrigin = (c) => {
|
|
1965
|
+
/** The site's own address (MATERIALIZE_SITE_URL, site.url), when it is set. */
|
|
1966
|
+
const siteOrigin = () => {
|
|
1714
1967
|
const site = ctx.config().site.url;
|
|
1715
1968
|
try {
|
|
1716
1969
|
if (site)
|
|
1717
1970
|
return new URL(site).origin;
|
|
1718
1971
|
}
|
|
1719
1972
|
catch { }
|
|
1720
|
-
return
|
|
1973
|
+
return null;
|
|
1721
1974
|
};
|
|
1722
|
-
|
|
1975
|
+
/**
|
|
1976
|
+
* The address links in emails point to: the site's own (MATERIALIZE_SITE_URL, site.url) when it
|
|
1977
|
+
* is known. The request's Host header is used only without it, as in local development: anyone
|
|
1978
|
+
* can send a request with a false Host, and a link to their server would carry the token there.
|
|
1979
|
+
*/
|
|
1980
|
+
const publicOrigin = (c) => siteOrigin() ?? new URL(c.req.url).origin;
|
|
1981
|
+
/**
|
|
1982
|
+
* The address for links in emails asked for by someone not signed in (a forgotten password):
|
|
1983
|
+
* never the Host header, which they choose, except on this computer during development. Null
|
|
1984
|
+
* when the site's address is not set: then no link is sent (Impostazioni → Sicurezza says so).
|
|
1985
|
+
*/
|
|
1986
|
+
const strangerOrigin = (c) => {
|
|
1987
|
+
const url = new URL(c.req.url);
|
|
1988
|
+
return siteOrigin() ?? (isLocalHost(url.hostname) ? url.origin : null);
|
|
1989
|
+
};
|
|
1990
|
+
const mailLink = async (origin, to, subject, lines, path) => {
|
|
1723
1991
|
const mail = await settings.get('mail');
|
|
1724
1992
|
if (!isConfigured(mail))
|
|
1725
1993
|
return false;
|
|
1726
1994
|
try {
|
|
1727
|
-
const url = new URL(path,
|
|
1995
|
+
const url = new URL(path, origin).toString();
|
|
1728
1996
|
await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
|
|
1729
1997
|
return true;
|
|
1730
1998
|
}
|
|
@@ -1749,7 +2017,9 @@ export function createApp(base) {
|
|
|
1749
2017
|
}),
|
|
1750
2018
|
}));
|
|
1751
2019
|
const before = await settings.get('mail');
|
|
1752
|
-
|
|
2020
|
+
// The saved password goes only to the server it was given for (see withSecrets).
|
|
2021
|
+
const sameHost = !!before && before.host.trim().toLowerCase() === input.mail.host.trim().toLowerCase();
|
|
2022
|
+
const pass = input.mail.pass === null ? '' : input.mail.pass || (sameHost ? before?.pass : '') || '';
|
|
1753
2023
|
const mail = { ...input.mail, pass };
|
|
1754
2024
|
await settings.set('mail', mail, c.get('user').id);
|
|
1755
2025
|
await record(c, 'settings.mail');
|
|
@@ -1763,8 +2033,8 @@ export function createApp(base) {
|
|
|
1763
2033
|
try {
|
|
1764
2034
|
await sendMail(mail, {
|
|
1765
2035
|
to,
|
|
1766
|
-
subject: 'Prova di invio da Materialize',
|
|
1767
|
-
text: 'Se leggi questo messaggio, l’invio delle email dal sito funziona.',
|
|
2036
|
+
subject: t('Prova di invio da Materialize'),
|
|
2037
|
+
text: t('Se leggi questo messaggio, l’invio delle email dal sito funziona.'),
|
|
1768
2038
|
});
|
|
1769
2039
|
}
|
|
1770
2040
|
catch (error) {
|
|
@@ -1805,7 +2075,7 @@ export function createApp(base) {
|
|
|
1805
2075
|
const readyAi = async () => {
|
|
1806
2076
|
const ai = await aiSettings();
|
|
1807
2077
|
if (!isReady(ai))
|
|
1808
|
-
throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
|
|
2078
|
+
throw new ApiError(400, 'ai_not_configured', t('L’AI non è configurata: Impostazioni → AI e MCP'));
|
|
1809
2079
|
return { ...ai, onUsage: countUsage };
|
|
1810
2080
|
};
|
|
1811
2081
|
const aiFailed = (error) => {
|
|
@@ -1830,7 +2100,9 @@ export function createApp(base) {
|
|
|
1830
2100
|
}),
|
|
1831
2101
|
}));
|
|
1832
2102
|
const before = await aiSettings();
|
|
1833
|
-
|
|
2103
|
+
// The saved key goes only to the service it was given for (see withSecrets).
|
|
2104
|
+
const sameService = before.provider === input.ai.provider && sameServer(before.baseUrl, input.ai.baseUrl);
|
|
2105
|
+
const apiKey = input.ai.apiKey === null ? '' : input.ai.apiKey?.trim() || (sameService ? before.apiKey : '');
|
|
1834
2106
|
const ai = { ...input.ai, apiKey };
|
|
1835
2107
|
await settings.set('ai', ai, c.get('user').id);
|
|
1836
2108
|
await record(c, 'settings.ai', ai.provider);
|
|
@@ -1869,7 +2141,7 @@ export function createApp(base) {
|
|
|
1869
2141
|
*/
|
|
1870
2142
|
app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
|
|
1871
2143
|
if (!allowAi(c.get('user').id))
|
|
1872
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2144
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
1873
2145
|
const ai = await readyAi();
|
|
1874
2146
|
const config = ctx.config();
|
|
1875
2147
|
const texts = [];
|
|
@@ -1886,7 +2158,7 @@ export function createApp(base) {
|
|
|
1886
2158
|
}
|
|
1887
2159
|
}
|
|
1888
2160
|
if (size < 300)
|
|
1889
|
-
throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
|
|
2161
|
+
throw new ApiError(400, 'too_little_text', t('Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano'));
|
|
1890
2162
|
const reply = await complete(ai, {
|
|
1891
2163
|
system: 'You describe the voice of a brand from the texts of its website, for a style guide the brand will reuse. Reply with JSON only, no code fences.',
|
|
1892
2164
|
prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
|
|
@@ -1916,7 +2188,7 @@ ${texts.join('\n---\n')}
|
|
|
1916
2188
|
address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
|
|
1917
2189
|
});
|
|
1918
2190
|
if (!parsed.success)
|
|
1919
|
-
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
2191
|
+
throw new ApiError(422, 'ai_failed', t('L’AI non ha risposto come doveva: riprova'));
|
|
1920
2192
|
return c.json({ voice: parsed.data, texts: texts.length });
|
|
1921
2193
|
});
|
|
1922
2194
|
// ---------- AI review before publishing ----------
|
|
@@ -1946,12 +2218,12 @@ ${texts.join('\n---\n')}
|
|
|
1946
2218
|
const existing = await loadDocument(c.req.param('id'));
|
|
1947
2219
|
const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
|
|
1948
2220
|
if (!allowAi(c.get('user').id))
|
|
1949
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2221
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
1950
2222
|
const ai = await readyAi();
|
|
1951
2223
|
const def = collection(existing.collection);
|
|
1952
2224
|
const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
|
|
1953
2225
|
if (!slots.length)
|
|
1954
|
-
throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
|
|
2226
|
+
throw new ApiError(400, 'empty_text', t('Non c’è testo da rivedere'));
|
|
1955
2227
|
const titles = await pageTitles();
|
|
1956
2228
|
const linkTo = (href) => {
|
|
1957
2229
|
const path = href.split('#')[0] || href;
|
|
@@ -1991,7 +2263,7 @@ ${listed.join('\n')}
|
|
|
1991
2263
|
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
1992
2264
|
}
|
|
1993
2265
|
catch {
|
|
1994
|
-
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
2266
|
+
throw new ApiError(422, 'ai_failed', t('L’AI non ha risposto come doveva: riprova'));
|
|
1995
2267
|
}
|
|
1996
2268
|
const issue = z.object({
|
|
1997
2269
|
slot: z.number().int().min(0),
|
|
@@ -2037,7 +2309,7 @@ ${listed.join('\n')}
|
|
|
2037
2309
|
? slots[input.slot]
|
|
2038
2310
|
: slots.find((s) => s.text.includes(input.find));
|
|
2039
2311
|
if (!slot)
|
|
2040
|
-
throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
|
|
2312
|
+
throw new ApiError(409, 'changed', t('Quel testo è cambiato: rifai la revisione'));
|
|
2041
2313
|
slot.set(slot.text.replace(input.find, input.replace));
|
|
2042
2314
|
return c.json({ data });
|
|
2043
2315
|
});
|
|
@@ -2067,7 +2339,7 @@ ${listed.join('\n')}
|
|
|
2067
2339
|
max: z.number().int().positive().max(100_000).optional(),
|
|
2068
2340
|
}));
|
|
2069
2341
|
if (!allowAi(c.get('user').id))
|
|
2070
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2342
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
2071
2343
|
const ai = await readyAi();
|
|
2072
2344
|
// Writing from scratch takes the whole voice; changing a text, only its style.
|
|
2073
2345
|
const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
|
|
@@ -2079,7 +2351,7 @@ ${listed.join('\n')}
|
|
|
2079
2351
|
const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
|
|
2080
2352
|
const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
|
|
2081
2353
|
if (!file)
|
|
2082
|
-
throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
|
|
2354
|
+
throw new ApiError(400, 'no_image', t('Scegli prima un’immagine della libreria'));
|
|
2083
2355
|
const data = (await readFile(file.path)).toString('base64');
|
|
2084
2356
|
// The text already there may credit the photographer or the source: that part stays.
|
|
2085
2357
|
const current = input.text.trim()
|
|
@@ -2094,7 +2366,7 @@ ${listed.join('\n')}
|
|
|
2094
2366
|
return c.json({ text });
|
|
2095
2367
|
}
|
|
2096
2368
|
if (!input.text.trim())
|
|
2097
|
-
throw new ApiError(400, 'empty_text', 'Non c’è testo su cui lavorare');
|
|
2369
|
+
throw new ApiError(400, 'empty_text', t('Non c’è testo su cui lavorare'));
|
|
2098
2370
|
const task = input.action === 'translate'
|
|
2099
2371
|
? `Translate it into ${input.instruction || 'English'}, naturally, as a native writer would.`
|
|
2100
2372
|
: input.action === 'custom'
|
|
@@ -2158,7 +2430,7 @@ ${listed.join('\n')}
|
|
|
2158
2430
|
}));
|
|
2159
2431
|
const user = c.get('user');
|
|
2160
2432
|
if (!allowAi(user.id))
|
|
2161
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2433
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
2162
2434
|
const ai = await readyAi();
|
|
2163
2435
|
const now = Date.now();
|
|
2164
2436
|
for (const [k, v] of chats)
|
|
@@ -2210,7 +2482,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2210
2482
|
if (limited && !sameDesign(before, page.nodes))
|
|
2211
2483
|
await send({
|
|
2212
2484
|
type: 'error',
|
|
2213
|
-
message: 'Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.',
|
|
2485
|
+
message: t('Puoi cambiare solo i contenuti: le modifiche al layout non sono state applicate.'),
|
|
2214
2486
|
});
|
|
2215
2487
|
else
|
|
2216
2488
|
await send({ type: 'page', nodes: page.nodes });
|
|
@@ -2223,7 +2495,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2223
2495
|
console.error(error);
|
|
2224
2496
|
// A failed turn leaves the conversation as it was before it.
|
|
2225
2497
|
chats.delete(key);
|
|
2226
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2498
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2227
2499
|
await send({ type: 'done', chatId });
|
|
2228
2500
|
}
|
|
2229
2501
|
});
|
|
@@ -2235,7 +2507,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2235
2507
|
app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2236
2508
|
const input = await body(c, z
|
|
2237
2509
|
.object({
|
|
2238
|
-
brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
|
|
2510
|
+
brief: z.string().trim().min(20, t('Scrivi qualche riga in più sull’attività')).max(6000).optional(),
|
|
2239
2511
|
/** A reply in a run already started, with its id. */
|
|
2240
2512
|
runId: z.string().max(64).optional(),
|
|
2241
2513
|
message: z.string().trim().min(1).max(4000).optional(),
|
|
@@ -2248,10 +2520,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2248
2520
|
.max(2)
|
|
2249
2521
|
.default([]),
|
|
2250
2522
|
})
|
|
2251
|
-
.refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
|
|
2523
|
+
.refine((v) => (v.runId ? !!v.message : !!v.brief), { message: t('Scrivi il brief') }));
|
|
2252
2524
|
const user = c.get('user');
|
|
2253
2525
|
if (!allowAi(user.id))
|
|
2254
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2526
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
2255
2527
|
const ai = await readyAi();
|
|
2256
2528
|
const now = Date.now();
|
|
2257
2529
|
for (const [k, v] of briefRuns)
|
|
@@ -2261,12 +2533,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2261
2533
|
const key = `${user.id}:${runId}`;
|
|
2262
2534
|
let run = input.runId ? briefRuns.get(key) : undefined;
|
|
2263
2535
|
if (input.runId && (!run || run.conv.provider !== ai.provider))
|
|
2264
|
-
throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
|
|
2536
|
+
throw new ApiError(409, 'run_expired', t('Questa conversazione non c’è più: parti da un nuovo brief'));
|
|
2265
2537
|
if (!run) {
|
|
2266
2538
|
const config = ctx.config();
|
|
2267
2539
|
const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
|
|
2268
2540
|
if (!pageCollections.length)
|
|
2269
|
-
throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
|
|
2541
|
+
throw new ApiError(400, 'no_pages', t('Il sito non ha una raccolta di pagine costruite sulla tela'));
|
|
2270
2542
|
const locale = config.locales.default;
|
|
2271
2543
|
const existing = [];
|
|
2272
2544
|
for (const d of config.collections.filter((x) => !x.singleton)) {
|
|
@@ -2336,7 +2608,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2336
2608
|
// What was made before the failure stays, as drafts: the panel lists it. The run ends.
|
|
2337
2609
|
briefRuns.delete(key);
|
|
2338
2610
|
await send({ type: 'result', ...result });
|
|
2339
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2611
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2340
2612
|
await send({ type: 'done' });
|
|
2341
2613
|
}
|
|
2342
2614
|
});
|
|
@@ -2357,7 +2629,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2357
2629
|
}));
|
|
2358
2630
|
const user = c.get('user');
|
|
2359
2631
|
if (!allowAi(user.id))
|
|
2360
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2632
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
2361
2633
|
const ai = await readyAi();
|
|
2362
2634
|
const now = Date.now();
|
|
2363
2635
|
for (const [k, v] of studioRuns)
|
|
@@ -2412,7 +2684,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2412
2684
|
if (!(error instanceof AiError))
|
|
2413
2685
|
console.error(error);
|
|
2414
2686
|
studioRuns.delete(key);
|
|
2415
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2687
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2416
2688
|
await send({ type: 'done' });
|
|
2417
2689
|
}
|
|
2418
2690
|
});
|
|
@@ -2469,8 +2741,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2469
2741
|
}), async (c) => {
|
|
2470
2742
|
const form = c.req.param('form').slice(0, 80);
|
|
2471
2743
|
const ip = clientIp(c);
|
|
2472
|
-
|
|
2473
|
-
|
|
2744
|
+
const tooMany = () => new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
|
|
2745
|
+
if (!allowFormIp(ip) || !allowFormIpHour(ip) || !allowForm(`${ip}:${form}`))
|
|
2746
|
+
throw tooMany();
|
|
2474
2747
|
const type = c.req.header('content-type') ?? '';
|
|
2475
2748
|
const isJson = type.includes('application/json');
|
|
2476
2749
|
const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
|
|
@@ -2481,6 +2754,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2481
2754
|
if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
|
|
2482
2755
|
return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
|
|
2483
2756
|
const def = (await publishedForms()).get(form) ?? null;
|
|
2757
|
+
if (!def && !allowHandForm(ip))
|
|
2758
|
+
throw tooMany();
|
|
2484
2759
|
const { data, files, issues } = await readSubmission(raw, def);
|
|
2485
2760
|
if (issues.length) {
|
|
2486
2761
|
if (!wantsJson)
|
|
@@ -2492,7 +2767,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2492
2767
|
const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
|
|
2493
2768
|
const fingerprint = fingerprintOf(form, data);
|
|
2494
2769
|
const spam = spamReason(data, elapsed) ??
|
|
2495
|
-
((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
|
|
2770
|
+
((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? t.keep('Messaggio ripetuto') : null);
|
|
2496
2771
|
// A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
|
|
2497
2772
|
if (def?.newsletter) {
|
|
2498
2773
|
if (!spam)
|
|
@@ -2501,7 +2776,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2501
2776
|
}
|
|
2502
2777
|
const formName = def?.name?.trim() || def?.subject?.trim() || '';
|
|
2503
2778
|
const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
|
|
2504
|
-
|
|
2779
|
+
// Spam keeps its words, for a check, but not its files: they would only fill the disk.
|
|
2780
|
+
if (files.length && !spam)
|
|
2505
2781
|
await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
|
|
2506
2782
|
// Spam stays in the panel, recoverable, but nobody is notified.
|
|
2507
2783
|
if (!spam) {
|
|
@@ -2510,13 +2786,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2510
2786
|
const sender = Object.entries(data).find(([k, v]) => (def?.fields.find((f) => f.name === k)?.kind === 'email' || /mail/i.test(k)) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
|
|
2511
2787
|
if (isConfigured(mail) && to) {
|
|
2512
2788
|
const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
: ''
|
|
2789
|
+
// The visitor's request says nothing about who reads this: it goes in the owner's language.
|
|
2790
|
+
const notice = withLang(await ownerLang(to), () => ({
|
|
2791
|
+
subject: subject ? t('Nuovo messaggio dal sito: {subject}', { subject }) : t('Nuovo messaggio dal sito'),
|
|
2792
|
+
text: submissionText(page, data) +
|
|
2793
|
+
(files.length
|
|
2794
|
+
? `\n\n${t('Allegati ({files}): li trovi in Impostazioni → Messaggi.', { files: files.map((f) => f.name).join(', ') })}`
|
|
2795
|
+
: ''),
|
|
2796
|
+
}));
|
|
2516
2797
|
sendMail(mail, {
|
|
2517
2798
|
to,
|
|
2518
|
-
|
|
2519
|
-
text: submissionText(page, data) + attached,
|
|
2799
|
+
...notice,
|
|
2520
2800
|
replyTo: sender,
|
|
2521
2801
|
})
|
|
2522
2802
|
.then(() => submissions.markMailed(saved.id))
|
|
@@ -2540,7 +2820,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2540
2820
|
createdAt: saved.createdAt.toISOString(),
|
|
2541
2821
|
};
|
|
2542
2822
|
void webhookSecret()
|
|
2543
|
-
.then((secret) => sendWebhook(def.webhook, payload, secret))
|
|
2823
|
+
.then((secret) => sendWebhook(def.webhook, payload, secret, undefined, privateWebhooks))
|
|
2544
2824
|
.then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
|
|
2545
2825
|
.catch((error) => console.error('Materialize: webhook not sent', error));
|
|
2546
2826
|
}
|
|
@@ -2556,8 +2836,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2556
2836
|
app.get('/api/submissions', requireUser, async (c) => {
|
|
2557
2837
|
const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
|
|
2558
2838
|
const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
|
|
2839
|
+
const list = await submissions.list(limit, offset, filterOf(c));
|
|
2559
2840
|
return c.json({
|
|
2560
|
-
...
|
|
2841
|
+
...list,
|
|
2842
|
+
// The spam reason is saved as a kept text: read in the language of whoever looks.
|
|
2843
|
+
items: list.items.map((m) => ({ ...m, spam: m.spam && t.read(m.spam) })),
|
|
2561
2844
|
forms: await submissions.forms(),
|
|
2562
2845
|
});
|
|
2563
2846
|
});
|
|
@@ -2571,7 +2854,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2571
2854
|
return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
2572
2855
|
};
|
|
2573
2856
|
const rows = [
|
|
2574
|
-
['Data', 'Modulo', 'Pagina', ...fields],
|
|
2857
|
+
[t('Data'), t('Modulo'), t('Pagina'), ...fields],
|
|
2575
2858
|
...items.map((m) => [
|
|
2576
2859
|
m.createdAt.toISOString(),
|
|
2577
2860
|
m.formName || m.form,
|
|
@@ -2595,7 +2878,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2595
2878
|
if (input.read !== undefined)
|
|
2596
2879
|
await submissions.markRead(id, input.read);
|
|
2597
2880
|
if (input.spam !== undefined)
|
|
2598
|
-
await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
|
|
2881
|
+
await submissions.markSpam(id, input.spam ? t.keep('Segnato a mano') : null);
|
|
2599
2882
|
return c.json({ ok: true });
|
|
2600
2883
|
});
|
|
2601
2884
|
/** A file sent with a message: always downloaded, never opened in the panel's own page. */
|
|
@@ -2603,10 +2886,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2603
2886
|
const message = await submissions.get(c.req.param('id'));
|
|
2604
2887
|
const file = message?.files[Number(c.req.param('n'))];
|
|
2605
2888
|
if (!message || !file)
|
|
2606
|
-
throw new ApiError(404, 'not_found', 'File non trovato');
|
|
2889
|
+
throw new ApiError(404, 'not_found', t('File non trovato'));
|
|
2607
2890
|
const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
|
|
2608
2891
|
if (!bytes)
|
|
2609
|
-
throw new ApiError(404, 'not_found', 'Il file non c’è più');
|
|
2892
|
+
throw new ApiError(404, 'not_found', t('Il file non c’è più'));
|
|
2610
2893
|
return new Response(bytes, {
|
|
2611
2894
|
headers: {
|
|
2612
2895
|
'content-type': 'application/octet-stream',
|
|
@@ -2674,14 +2957,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2674
2957
|
const category = z.enum(['necessary', 'analytics', 'marketing']);
|
|
2675
2958
|
const input = await body(c, z.object({
|
|
2676
2959
|
tracking: z.object({
|
|
2677
|
-
ga4: id(TRACKING_PATTERNS.ga4, 'Un ID di Google Analytics 4 inizia con G-'),
|
|
2678
|
-
gtm: id(TRACKING_PATTERNS.gtm, 'Un ID di Tag Manager inizia con GTM-'),
|
|
2960
|
+
ga4: id(TRACKING_PATTERNS.ga4, t('Un ID di Google Analytics 4 inizia con G-')),
|
|
2961
|
+
gtm: id(TRACKING_PATTERNS.gtm, t('Un ID di Tag Manager inizia con GTM-')),
|
|
2679
2962
|
gtmCategory: category.optional(),
|
|
2680
|
-
metaPixel: id(TRACKING_PATTERNS.metaPixel, 'L’ID del Pixel è fatto di sole cifre'),
|
|
2681
|
-
plausible: id(TRACKING_PATTERNS.plausible, 'Scrivi il dominio come su Plausible, per esempio example.com'),
|
|
2682
|
-
plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, 'Un indirizzo https://'),
|
|
2683
|
-
matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, 'L’indirizzo di Matomo, con https://'),
|
|
2684
|
-
matomoSiteId: id(TRACKING_PATTERNS.matomoSiteId, 'Il numero del sito su Matomo'),
|
|
2963
|
+
metaPixel: id(TRACKING_PATTERNS.metaPixel, t('L’ID del Pixel è fatto di sole cifre')),
|
|
2964
|
+
plausible: id(TRACKING_PATTERNS.plausible, t('Scrivi il dominio come su Plausible, per esempio example.com')),
|
|
2965
|
+
plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, t('Un indirizzo https://')),
|
|
2966
|
+
matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, t('L’indirizzo di Matomo, con https://')),
|
|
2967
|
+
matomoSiteId: id(TRACKING_PATTERNS.matomoSiteId, t('Il numero del sito su Matomo')),
|
|
2685
2968
|
custom: z
|
|
2686
2969
|
.array(z.object({
|
|
2687
2970
|
id: z.string().min(1).max(40),
|
|
@@ -2691,7 +2974,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2691
2974
|
code: z
|
|
2692
2975
|
.string()
|
|
2693
2976
|
.max(20_000)
|
|
2694
|
-
.refine((v) => !/<\/template/i.test(v), 'Il codice non può contenere </template>'),
|
|
2977
|
+
.refine((v) => !/<\/template/i.test(v), t('Il codice non può contenere </template>')),
|
|
2695
2978
|
}))
|
|
2696
2979
|
.max(30)
|
|
2697
2980
|
.optional(),
|
|
@@ -2699,7 +2982,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2699
2982
|
.object({
|
|
2700
2983
|
title: z.string().trim().max(120).optional(),
|
|
2701
2984
|
text: z.string().trim().max(600).optional(),
|
|
2702
|
-
policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, 'Un indirizzo che inizia con / o https://'),
|
|
2985
|
+
policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, t('Un indirizzo che inizia con / o https://')),
|
|
2703
2986
|
position: z.enum(['bottom', 'corner']).optional(),
|
|
2704
2987
|
reopenButton: z.boolean().optional(),
|
|
2705
2988
|
})
|
|
@@ -2888,6 +3171,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2888
3171
|
app.get('/api/documents/:id', requireUser, async (c) => c.json(await toJson(await loadDocument(c.req.param('id')))));
|
|
2889
3172
|
app.put('/api/documents/:id', requireUser, async (c) => {
|
|
2890
3173
|
const existing = await loadDocument(c.req.param('id'));
|
|
3174
|
+
guardTurn(c, existing.id);
|
|
2891
3175
|
const input = await body(c, documentBody);
|
|
2892
3176
|
const def = collection(existing.collection);
|
|
2893
3177
|
const draft = draftData(def, input.data);
|
|
@@ -2960,18 +3244,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2960
3244
|
}
|
|
2961
3245
|
const at = new Date(input.at);
|
|
2962
3246
|
if (at.getTime() <= Date.now() + 30_000)
|
|
2963
|
-
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3247
|
+
throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
|
|
2964
3248
|
const current = await schedules.forDocument(existing.id);
|
|
2965
3249
|
if (input.action === 'publish')
|
|
2966
3250
|
publishableData(collection(existing.collection), existing.data);
|
|
2967
3251
|
else {
|
|
2968
3252
|
if (!existing.publishedAt && !current.publish)
|
|
2969
|
-
throw new ApiError(409, 'not_published', 'La pagina non è sul sito: programma prima la pubblicazione');
|
|
3253
|
+
throw new ApiError(409, 'not_published', t('La pagina non è sul sito: programma prima la pubblicazione'));
|
|
2970
3254
|
if (current.publish && current.publish.runAt >= at)
|
|
2971
|
-
throw new ApiError(400, 'before_publish', 'La pagina verrebbe tolta prima di essere pubblicata');
|
|
3255
|
+
throw new ApiError(400, 'before_publish', t('La pagina verrebbe tolta prima di essere pubblicata'));
|
|
2972
3256
|
}
|
|
2973
3257
|
if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
|
|
2974
|
-
throw new ApiError(400, 'after_unpublish', 'La pubblicazione verrebbe dopo il momento in cui la pagina va tolta');
|
|
3258
|
+
throw new ApiError(400, 'after_unpublish', t('La pubblicazione verrebbe dopo il momento in cui la pagina va tolta'));
|
|
2975
3259
|
await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
|
|
2976
3260
|
await record(c, 'document.scheduled', existing.title, {
|
|
2977
3261
|
collection: existing.collection,
|
|
@@ -2985,12 +3269,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2985
3269
|
const publishProblem = (doc) => {
|
|
2986
3270
|
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
2987
3271
|
if (!def)
|
|
2988
|
-
return 'La raccolta non esiste più';
|
|
3272
|
+
return t.keep('La raccolta non esiste più');
|
|
2989
3273
|
const check = validate(def, prepare(def, doc.data));
|
|
2990
3274
|
if (check.ok)
|
|
2991
3275
|
return null;
|
|
2992
3276
|
const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
|
|
2993
|
-
return
|
|
3277
|
+
return fields.length
|
|
3278
|
+
? t.keep('Alcuni campi vanno completati: {fields}', { fields: fields.join(', ') })
|
|
3279
|
+
: t.keep('Alcuni campi vanno completati');
|
|
2994
3280
|
};
|
|
2995
3281
|
/**
|
|
2996
3282
|
* Runs the actions whose time has come, as the person who set them. A server that was off at the
|
|
@@ -3017,7 +3303,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3017
3303
|
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3018
3304
|
const existing = await content.get(item.documentId);
|
|
3019
3305
|
if (!existing) {
|
|
3020
|
-
await schedules.finish(item.id, 'failed', 'La pagina non esiste più');
|
|
3306
|
+
await schedules.finish(item.id, 'failed', t.keep('La pagina non esiste più'));
|
|
3021
3307
|
continue;
|
|
3022
3308
|
}
|
|
3023
3309
|
try {
|
|
@@ -3037,7 +3323,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3037
3323
|
ran++;
|
|
3038
3324
|
}
|
|
3039
3325
|
catch (error) {
|
|
3040
|
-
const message = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
|
|
3326
|
+
const message = error instanceof Error && error.message ? error.message : t.keep('Errore imprevisto');
|
|
3041
3327
|
await schedules.finish(item.id, 'failed', message);
|
|
3042
3328
|
await note('document.schedule_failed', existing.title, {
|
|
3043
3329
|
collection: existing.collection,
|
|
@@ -3083,7 +3369,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3083
3369
|
for (const item of release.items) {
|
|
3084
3370
|
const doc = await content.get(item.documentId);
|
|
3085
3371
|
if (!doc)
|
|
3086
|
-
out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
|
|
3372
|
+
out.push({ documentId: item.documentId, title: '', message: t.keep('La pagina non esiste più') });
|
|
3087
3373
|
else if (item.action === 'publish') {
|
|
3088
3374
|
const message = publishProblem(doc);
|
|
3089
3375
|
if (message)
|
|
@@ -3091,7 +3377,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3091
3377
|
}
|
|
3092
3378
|
}
|
|
3093
3379
|
if (!release.items.length)
|
|
3094
|
-
out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
|
|
3380
|
+
out.push({ documentId: '', title: '', message: t.keep('Il rilascio è vuoto') });
|
|
3095
3381
|
return out;
|
|
3096
3382
|
};
|
|
3097
3383
|
/**
|
|
@@ -3104,7 +3390,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3104
3390
|
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3105
3391
|
const problems = await releaseProblems(release);
|
|
3106
3392
|
if (problems.length) {
|
|
3107
|
-
const message =
|
|
3393
|
+
const message = t.keep('{list}', {
|
|
3394
|
+
list: problems.map((p) => p.title ? t.keep('{title}: {message}', { title: p.title, message: p.message }) : p.message),
|
|
3395
|
+
});
|
|
3108
3396
|
await releases.finish(release.id, 'failed', message);
|
|
3109
3397
|
await note('release.failed', release.name, { id: release.id, message });
|
|
3110
3398
|
return false;
|
|
@@ -3127,8 +3415,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3127
3415
|
}
|
|
3128
3416
|
}
|
|
3129
3417
|
catch (error) {
|
|
3130
|
-
const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
|
|
3131
|
-
const message =
|
|
3418
|
+
const why = error instanceof Error && error.message ? error.message : t.keep('Errore imprevisto');
|
|
3419
|
+
const message = done.length ? t.keep('{why} (già uscite: {done})', { why, done: done.join(', ') }) : why;
|
|
3132
3420
|
await releases.finish(release.id, 'failed', message);
|
|
3133
3421
|
await note('release.failed', release.name, { id: release.id, message });
|
|
3134
3422
|
return false;
|
|
@@ -3151,21 +3439,21 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3151
3439
|
collection: doc.collection,
|
|
3152
3440
|
locale: doc.locale,
|
|
3153
3441
|
status: doc.status,
|
|
3154
|
-
problem: problems.find((p) => p.documentId === doc.id)?.message
|
|
3442
|
+
problem: t.read(problems.find((p) => p.documentId === doc.id)?.message) || null,
|
|
3155
3443
|
});
|
|
3156
3444
|
}
|
|
3157
|
-
return { ...release, items, ready: problems.length === 0 };
|
|
3445
|
+
return { ...release, error: release.error && t.read(release.error), items, ready: problems.length === 0 };
|
|
3158
3446
|
};
|
|
3159
3447
|
const loadRelease = async (id) => {
|
|
3160
3448
|
const release = await releases.get(id);
|
|
3161
3449
|
if (!release)
|
|
3162
|
-
throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
|
|
3450
|
+
throw new ApiError(404, 'not_found', t('Questo rilascio non esiste'));
|
|
3163
3451
|
return release;
|
|
3164
3452
|
};
|
|
3165
3453
|
const openRelease = async (id) => {
|
|
3166
3454
|
const release = await loadRelease(id);
|
|
3167
3455
|
if (release.status === 'published' || release.status === 'cancelled')
|
|
3168
|
-
throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
|
|
3456
|
+
throw new ApiError(409, 'release_closed', t('Questo rilascio è già uscito o è stato annullato'));
|
|
3169
3457
|
return release;
|
|
3170
3458
|
};
|
|
3171
3459
|
app.get('/api/releases', requireUser, async (c) => {
|
|
@@ -3201,7 +3489,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3201
3489
|
const doc = await loadDocument(c.req.param('documentId'));
|
|
3202
3490
|
const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
|
|
3203
3491
|
if (input.action === 'unpublish' && !doc.publishedAt)
|
|
3204
|
-
throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
|
|
3492
|
+
throw new ApiError(409, 'not_published', t('La pagina non è sul sito: non c’è niente da togliere'));
|
|
3205
3493
|
await releases.put(release.id, doc.id, input.action);
|
|
3206
3494
|
await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
|
|
3207
3495
|
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
@@ -3219,15 +3507,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3219
3507
|
return c.json({
|
|
3220
3508
|
error: {
|
|
3221
3509
|
code: 'release_not_ready',
|
|
3222
|
-
message: 'Alcune pagine non sono pronte: non è uscito niente',
|
|
3223
|
-
problems,
|
|
3510
|
+
message: t('Alcune pagine non sono pronte: non è uscito niente'),
|
|
3511
|
+
problems: problems.map((p) => ({ ...p, message: t.read(p.message) })),
|
|
3224
3512
|
},
|
|
3225
3513
|
}, 422);
|
|
3226
3514
|
const user = c.get('user');
|
|
3227
3515
|
const ok = await releaseOut(release, user);
|
|
3228
3516
|
const after = await loadRelease(release.id);
|
|
3229
3517
|
if (!ok)
|
|
3230
|
-
throw new ApiError(422, 'release_failed', after.error
|
|
3518
|
+
throw new ApiError(422, 'release_failed', after.error ? t.read(after.error) : t('Il rilascio non è uscito'));
|
|
3231
3519
|
return c.json(await releaseJson(after));
|
|
3232
3520
|
});
|
|
3233
3521
|
/** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
|
|
@@ -3242,9 +3530,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3242
3530
|
}
|
|
3243
3531
|
const at = new Date(input.at);
|
|
3244
3532
|
if (at.getTime() <= Date.now() + 30_000)
|
|
3245
|
-
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3533
|
+
throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
|
|
3246
3534
|
if (!release.items.length)
|
|
3247
|
-
throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
|
|
3535
|
+
throw new ApiError(400, 'empty_release', t('Aggiungi prima qualcosa al rilascio'));
|
|
3248
3536
|
await releases.schedule(release.id, at, user);
|
|
3249
3537
|
await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
|
|
3250
3538
|
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
@@ -3413,7 +3701,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3413
3701
|
app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
|
|
3414
3702
|
const found = await newsletter.subscriber(c.req.param('id'));
|
|
3415
3703
|
if (!found)
|
|
3416
|
-
throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
|
|
3704
|
+
throw new ApiError(404, 'not_found', t('Questo iscritto non esiste'));
|
|
3417
3705
|
await newsletter.removeSubscriber(found.id);
|
|
3418
3706
|
await record(c, 'newsletter.subscriber_removed', found.email);
|
|
3419
3707
|
return c.body(null, 204);
|
|
@@ -3422,7 +3710,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3422
3710
|
const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
|
|
3423
3711
|
const rows = parseSubscribersCsv(input.csv);
|
|
3424
3712
|
if (!rows.length)
|
|
3425
|
-
throw new ApiError(422, 'no_addresses', 'Nel file non ho trovato indirizzi email');
|
|
3713
|
+
throw new ApiError(422, 'no_addresses', t('Nel file non ho trovato indirizzi email'));
|
|
3426
3714
|
const result = await newsletter.importMany(rows);
|
|
3427
3715
|
await record(c, 'newsletter.imported', '', result);
|
|
3428
3716
|
return c.json({ found: rows.length, ...result });
|
|
@@ -3461,16 +3749,20 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3461
3749
|
return c.json(input);
|
|
3462
3750
|
});
|
|
3463
3751
|
// ----- panel: newsletters -----
|
|
3464
|
-
const newsletterJson = async (n) => ({
|
|
3752
|
+
const newsletterJson = async (n) => ({
|
|
3753
|
+
...n,
|
|
3754
|
+
error: n.error && t.read(n.error),
|
|
3755
|
+
stats: await newsletter.stats(n.id),
|
|
3756
|
+
});
|
|
3465
3757
|
const loadNewsletter = async (id) => {
|
|
3466
3758
|
const found = await newsletter.get(id);
|
|
3467
3759
|
if (!found)
|
|
3468
|
-
throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
|
|
3760
|
+
throw new ApiError(404, 'not_found', t('Questa newsletter non esiste'));
|
|
3469
3761
|
return found;
|
|
3470
3762
|
};
|
|
3471
3763
|
const editable = (n) => {
|
|
3472
3764
|
if (n.status === 'sending' || n.status === 'sent')
|
|
3473
|
-
throw new ApiError(409, 'newsletter_sent', 'Questa newsletter è già partita: non si può più cambiare');
|
|
3765
|
+
throw new ApiError(409, 'newsletter_sent', t('Questa newsletter è già partita: non si può più cambiare'));
|
|
3474
3766
|
};
|
|
3475
3767
|
const newsletterBody = z.object({
|
|
3476
3768
|
subject: z.string().trim().min(1).max(200),
|
|
@@ -3495,7 +3787,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3495
3787
|
app.delete('/api/newsletters/:id', requireUser, async (c) => {
|
|
3496
3788
|
const found = await loadNewsletter(c.req.param('id'));
|
|
3497
3789
|
if (found.status === 'sending')
|
|
3498
|
-
throw new ApiError(409, 'newsletter_sending', 'La newsletter sta partendo: aspetta che finisca');
|
|
3790
|
+
throw new ApiError(409, 'newsletter_sending', t('La newsletter sta partendo: aspetta che finisca'));
|
|
3499
3791
|
await newsletter.remove(found.id);
|
|
3500
3792
|
await record(c, 'newsletter.deleted', found.subject);
|
|
3501
3793
|
return c.body(null, 204);
|
|
@@ -3538,17 +3830,25 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3538
3830
|
const readyMail = async () => {
|
|
3539
3831
|
const mail = await settings.get('mail');
|
|
3540
3832
|
if (!isConfigured(mail))
|
|
3541
|
-
throw new ApiError(400, 'mail_not_configured', 'Per inviare la newsletter serve l’email: Impostazioni → Email');
|
|
3833
|
+
throw new ApiError(400, 'mail_not_configured', t('Per inviare la newsletter serve l’email: Impostazioni → Email'));
|
|
3542
3834
|
return mail;
|
|
3543
3835
|
};
|
|
3544
3836
|
/** A try, to the person writing it (or another address): nothing is counted. */
|
|
3837
|
+
// Each try is an email to any address: a few an hour are plenty to check one.
|
|
3838
|
+
const allowNewsletterTest = createLimiter(10, 60 * 60_000);
|
|
3545
3839
|
app.post('/api/newsletters/:id/test', requireUser, async (c) => {
|
|
3840
|
+
if (!allowNewsletterTest(c.get('user').id))
|
|
3841
|
+
throw new ApiError(429, 'too_many_attempts', t('Troppe prove: riprova tra un’ora'));
|
|
3546
3842
|
const found = await loadNewsletter(c.req.param('id'));
|
|
3547
3843
|
const input = await body(c, z.object({ to: z.email().optional() }));
|
|
3548
3844
|
const mail = await readyMail();
|
|
3549
3845
|
await newsletterOrigin(c);
|
|
3550
3846
|
const message = await composeNewsletter(found, null);
|
|
3551
|
-
await sendMail(mail, {
|
|
3847
|
+
await sendMail(mail, {
|
|
3848
|
+
to: input.to ?? c.get('user').email,
|
|
3849
|
+
...message,
|
|
3850
|
+
subject: t('[Prova] {subject}', { subject: message.subject }),
|
|
3851
|
+
});
|
|
3552
3852
|
return c.json({ ok: true });
|
|
3553
3853
|
});
|
|
3554
3854
|
app.post('/api/newsletters/:id/send', requireUser, async (c) => {
|
|
@@ -3558,7 +3858,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3558
3858
|
await newsletterOrigin(c);
|
|
3559
3859
|
const { counts } = await newsletter.subscribers({ limit: 1 });
|
|
3560
3860
|
if (!counts.active)
|
|
3561
|
-
throw new ApiError(400, 'no_subscribers', 'Non ci sono ancora iscritti attivi');
|
|
3861
|
+
throw new ApiError(400, 'no_subscribers', t('Non ci sono ancora iscritti attivi'));
|
|
3562
3862
|
await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
|
|
3563
3863
|
await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
|
|
3564
3864
|
void runNewsletters();
|
|
@@ -3574,7 +3874,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3574
3874
|
}
|
|
3575
3875
|
const at = new Date(input.at);
|
|
3576
3876
|
if (at.getTime() <= Date.now() + 30_000)
|
|
3577
|
-
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3877
|
+
throw new ApiError(400, 'past_time', t('Scegli un momento nel futuro, almeno tra un minuto'));
|
|
3578
3878
|
await readyMail();
|
|
3579
3879
|
await newsletterOrigin(c);
|
|
3580
3880
|
await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
|
|
@@ -3624,7 +3924,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3624
3924
|
const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
|
|
3625
3925
|
const user = c.get('user');
|
|
3626
3926
|
if (!allowAi(user.id))
|
|
3627
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
3927
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
3628
3928
|
const ai = await readyAi();
|
|
3629
3929
|
const config = ctx.config();
|
|
3630
3930
|
const lang = config.locales.default;
|
|
@@ -3654,7 +3954,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3654
3954
|
items.sort((a, b) => b.date - a.date);
|
|
3655
3955
|
const latest = items.slice(0, input.count);
|
|
3656
3956
|
if (!latest.length)
|
|
3657
|
-
throw new ApiError(400, 'no_articles', 'Non ci sono ancora articoli pubblicati da raccontare');
|
|
3957
|
+
throw new ApiError(400, 'no_articles', t('Non ci sono ancora articoli pubblicati da raccontare'));
|
|
3658
3958
|
const voice = await voiceText();
|
|
3659
3959
|
const system = `${voice ? `${voice}\n\n` : ''}You write the email newsletter of the website "${config.site.name}", in ${languageName(lang)}. You get its latest articles; write a short newsletter that makes people want to read them: a subject (at most 70 characters), a preheader (one sentence, at most 120 characters), an opening of one or two sentences, then for each article a heading with its title and two or three sentences on why it is worth reading, ending with a link to it. Use only facts from the articles. Reply with JSON only: {"subject": "...", "preheader": "...", "blocks": [{"type": "paragraph"|"heading", "text": "...", "link": {"text": "...", "href": "..."}?}]}.`;
|
|
3660
3960
|
const prompt = `${input.note ? `<note>${input.note}</note>\n` : ''}${latest.map((a) => `<article title="${a.title.replace(/"/g, "'")}" url="${a.url}">\n${a.text}\n</article>`).join('\n')}`;
|
|
@@ -3664,7 +3964,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3664
3964
|
parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
|
|
3665
3965
|
}
|
|
3666
3966
|
catch {
|
|
3667
|
-
throw new ApiError(422, 'ai_failed', 'La risposta dell’AI non era leggibile: riprova');
|
|
3967
|
+
throw new ApiError(422, 'ai_failed', t('La risposta dell’AI non era leggibile: riprova'));
|
|
3668
3968
|
}
|
|
3669
3969
|
const known = new Set(latest.map((a) => a.url));
|
|
3670
3970
|
const blocks = [];
|
|
@@ -3700,7 +4000,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3700
4000
|
for (const n of await newsletter.due(now)) {
|
|
3701
4001
|
const mail = await settings.get('mail');
|
|
3702
4002
|
if (!isConfigured(mail)) {
|
|
3703
|
-
await newsletter.setStatus(n.id, 'failed', { error: 'L’email non è configurata' });
|
|
4003
|
+
await newsletter.setStatus(n.id, 'failed', { error: t.keep('L’email non è configurata') });
|
|
3704
4004
|
continue;
|
|
3705
4005
|
}
|
|
3706
4006
|
if (n.status === 'scheduled')
|
|
@@ -3713,7 +4013,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3713
4013
|
for (const item of batch) {
|
|
3714
4014
|
const who = await newsletter.subscriber(item.subscriberId);
|
|
3715
4015
|
if (!who || who.status !== 'active') {
|
|
3716
|
-
await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
|
|
4016
|
+
await newsletter.markSend(item.id, 'failed', t.keep('Non più iscritto'));
|
|
3717
4017
|
continue;
|
|
3718
4018
|
}
|
|
3719
4019
|
try {
|
|
@@ -3723,7 +4023,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3723
4023
|
sent++;
|
|
3724
4024
|
}
|
|
3725
4025
|
catch (error) {
|
|
3726
|
-
await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : 'Errore');
|
|
4026
|
+
await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : t.keep('Errore'));
|
|
3727
4027
|
}
|
|
3728
4028
|
if (pause)
|
|
3729
4029
|
await new Promise((r) => setTimeout(r, pause));
|
|
@@ -3733,7 +4033,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3733
4033
|
const allFailed = stats.total > 0 && stats.sent === 0;
|
|
3734
4034
|
await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
|
|
3735
4035
|
sentAt: new Date(),
|
|
3736
|
-
error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
|
|
4036
|
+
error: allFailed ? t.keep('Nessun invio riuscito: controlla Impostazioni → Email') : null,
|
|
3737
4037
|
});
|
|
3738
4038
|
}
|
|
3739
4039
|
// Signups never confirmed are dropped after a week.
|
|
@@ -3766,9 +4066,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3766
4066
|
const existing = await loadDocument(c.req.param('id'));
|
|
3767
4067
|
const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
|
|
3768
4068
|
if (!ctx.preview)
|
|
3769
|
-
throw new ApiError(409, 'no_site', 'Questo sito non ha pagine da mostrare in anteprima');
|
|
4069
|
+
throw new ApiError(409, 'no_site', t('Questo sito non ha pagine da mostrare in anteprima'));
|
|
3770
4070
|
if (!collection(existing.collection).url)
|
|
3771
|
-
throw new ApiError(400, 'no_page', 'Questo contenuto non ha una pagina sul sito');
|
|
4071
|
+
throw new ApiError(400, 'no_page', t('Questo contenuto non ha una pagina sul sito'));
|
|
3772
4072
|
const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
|
|
3773
4073
|
const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
|
|
3774
4074
|
await record(c, 'document.preview_shared', existing.title, {
|
|
@@ -3781,7 +4081,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3781
4081
|
app.delete('/api/previews/:id', requireUser, async (c) => {
|
|
3782
4082
|
const link = await previewLinks.get(c.req.param('id'));
|
|
3783
4083
|
if (!link)
|
|
3784
|
-
throw new ApiError(404, 'not_found', 'Link non trovato');
|
|
4084
|
+
throw new ApiError(404, 'not_found', t('Link non trovato'));
|
|
3785
4085
|
const doc = await content.get(link.documentId);
|
|
3786
4086
|
await previewLinks.revoke(link.id);
|
|
3787
4087
|
await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
|
|
@@ -3811,6 +4111,16 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3811
4111
|
});
|
|
3812
4112
|
app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
|
|
3813
4113
|
const existing = await loadDocument(c.req.param('id'));
|
|
4114
|
+
guardTurn(c, existing.id);
|
|
4115
|
+
// An old version is a save like any other: it may hold code or a form's address an editor
|
|
4116
|
+
// could not write today (an admin's version, or one from before the rules changed).
|
|
4117
|
+
const revision = await content.revision(existing.id, c.req.param('revision'));
|
|
4118
|
+
if (!revision)
|
|
4119
|
+
throw new ApiError(404, 'not_found', 'This version does not exist');
|
|
4120
|
+
guardEmbeds(c.get('user'), existing.data, revision.data);
|
|
4121
|
+
for (const [name, f] of Object.entries(collection(existing.collection).fields))
|
|
4122
|
+
if (f.kind === 'page')
|
|
4123
|
+
await guardDesign(c.get('user'), existing.data[name], revision.data[name]);
|
|
3814
4124
|
const doc = await content.restore(existing.id, c.req.param('revision'), c.get('user').id);
|
|
3815
4125
|
if (!doc)
|
|
3816
4126
|
throw new ApiError(404, 'not_found', 'This version does not exist');
|
|
@@ -3832,11 +4142,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3832
4142
|
});
|
|
3833
4143
|
app.put('/api/locales', requireUser, requireSession, requireAdmin, async (c) => {
|
|
3834
4144
|
const input = await body(c, z.object({
|
|
3835
|
-
default: z.string().refine(isLocaleCode, 'Codice lingua non valido'),
|
|
3836
|
-
available: z
|
|
4145
|
+
default: z.string().refine(isLocaleCode, t('Codice lingua non valido')),
|
|
4146
|
+
available: z
|
|
4147
|
+
.array(z.string().refine(isLocaleCode, t('Codice lingua non valido')))
|
|
4148
|
+
.min(1)
|
|
4149
|
+
.max(30),
|
|
3837
4150
|
}));
|
|
3838
4151
|
if (!input.available.includes(input.default))
|
|
3839
|
-
throw new ApiError(400, 'invalid_locales', 'La lingua principale deve essere tra quelle del sito');
|
|
4152
|
+
throw new ApiError(400, 'invalid_locales', t('La lingua principale deve essere tra quelle del sito'));
|
|
3840
4153
|
const value = { default: input.default, available: [...new Set(input.available)] };
|
|
3841
4154
|
await settings.set('locales', value, c.get('user').id);
|
|
3842
4155
|
languages = value;
|
|
@@ -3874,11 +4187,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3874
4187
|
const lang = locale(input.locale);
|
|
3875
4188
|
const group = await content.translations(doc.id);
|
|
3876
4189
|
if (group.some((t) => t.locale === lang))
|
|
3877
|
-
throw new ApiError(409, 'translation_exists', 'Questa lingua esiste già: aprila dall’elenco delle lingue');
|
|
4190
|
+
throw new ApiError(409, 'translation_exists', t('Questa lingua esiste già: aprila dall’elenco delle lingue'));
|
|
3878
4191
|
if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0)
|
|
3879
|
-
throw new ApiError(409, 'singleton_exists',
|
|
4192
|
+
throw new ApiError(409, 'singleton_exists', t('"{label}" esiste già in questa lingua', { label: def.label }));
|
|
3880
4193
|
if (input.translate && !allowAi(c.get('user').id))
|
|
3881
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
4194
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
3882
4195
|
const data = structuredClone(doc.data);
|
|
3883
4196
|
if (input.translate) {
|
|
3884
4197
|
// The address is translated too ("chi-siamo" → "about-us"), the home page keeps its own.
|
|
@@ -3913,7 +4226,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3913
4226
|
const input = await body(c, z.object({ translate: z.boolean().default(false) }));
|
|
3914
4227
|
const doc = await loadDocument(c.req.param('id'));
|
|
3915
4228
|
if (!doc.sourceId)
|
|
3916
|
-
throw new ApiError(400, 'not_a_translation', 'Questa è la versione originale');
|
|
4229
|
+
throw new ApiError(400, 'not_a_translation', t('Questa è la versione originale'));
|
|
3917
4230
|
const original = await loadDocument(doc.sourceId);
|
|
3918
4231
|
const def = collection(doc.collection);
|
|
3919
4232
|
const data = structuredClone(doc.data);
|
|
@@ -3929,7 +4242,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3929
4242
|
}
|
|
3930
4243
|
if (input.translate && fresh.size) {
|
|
3931
4244
|
if (!allowAi(c.get('user').id))
|
|
3932
|
-
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
4245
|
+
throw new ApiError(429, 'too_many_requests', t('Troppe richieste: aspetta un minuto'));
|
|
3933
4246
|
const ai = await readyAi();
|
|
3934
4247
|
for (const [name, f] of Object.entries(def.fields)) {
|
|
3935
4248
|
if (f.kind !== 'page' || !Array.isArray(data[name]))
|
|
@@ -3967,7 +4280,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3967
4280
|
wpJobs.delete(k);
|
|
3968
4281
|
};
|
|
3969
4282
|
/** A WordPress site is read from its address (REST API) or from its export file. */
|
|
3970
|
-
app.post('/api/import/wordpress/analyze', requireUser, requireSession, requireAdmin,
|
|
4283
|
+
app.post('/api/import/wordpress/analyze', requireUser, requireSession, requireAdmin, bodyLimit({
|
|
4284
|
+
maxSize: WXR_MAX,
|
|
4285
|
+
onError: () => {
|
|
4286
|
+
throw new ApiError(413, 'file_too_large', t('Il file è troppo grande'));
|
|
4287
|
+
},
|
|
4288
|
+
}), async (c) => {
|
|
3971
4289
|
forget();
|
|
3972
4290
|
const isForm = (c.req.header('content-type') ?? '').includes('multipart/form-data');
|
|
3973
4291
|
let site;
|
|
@@ -4001,7 +4319,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
4001
4319
|
if (error instanceof WordPressError)
|
|
4002
4320
|
throw new ApiError(422, 'wordpress_unreadable', error.message);
|
|
4003
4321
|
if (error instanceof RemoteError)
|
|
4004
|
-
throw new ApiError(422, 'wordpress_unreadable',
|
|
4322
|
+
throw new ApiError(422, 'wordpress_unreadable', t('Il sito non risponde: {message}', { message: error.message }));
|
|
4005
4323
|
throw error;
|
|
4006
4324
|
}
|
|
4007
4325
|
const token = randomBytes(16).toString('hex');
|
|
@@ -4058,12 +4376,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
4058
4376
|
const relayoutPage = async (ai, id, user, credentials) => {
|
|
4059
4377
|
const doc = await content.get(id);
|
|
4060
4378
|
if (!doc)
|
|
4061
|
-
return 'la pagina non c’è più';
|
|
4379
|
+
return t('la pagina non c’è più');
|
|
4062
4380
|
const def = collection(doc.collection);
|
|
4063
4381
|
const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
|
|
4064
4382
|
const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
|
|
4065
4383
|
if (!field || !before?.length)
|
|
4066
|
-
return 'la pagina è vuota';
|
|
4384
|
+
return t('la pagina è vuota');
|
|
4067
4385
|
const page = { nodes: before };
|
|
4068
4386
|
const call = apiCaller(app.fetch, credentials);
|
|
4069
4387
|
const getSchema = () => call('GET', '/api/schema');
|
|
@@ -4155,15 +4473,15 @@ When done, reply with one short sentence.`;
|
|
|
4155
4473
|
}
|
|
4156
4474
|
catch (error) {
|
|
4157
4475
|
return {
|
|
4158
|
-
error: error instanceof RemoteError &&
|
|
4159
|
-
? 'troppo grandi (oltre 25 MB)'
|
|
4160
|
-
: 'non raggiungibili',
|
|
4476
|
+
error: error instanceof RemoteError && error.reason === 'too_large'
|
|
4477
|
+
? t('troppo grandi (oltre 25 MB)')
|
|
4478
|
+
: t('non raggiungibili'),
|
|
4161
4479
|
};
|
|
4162
4480
|
}
|
|
4163
4481
|
if (res.status !== 200)
|
|
4164
|
-
return { error:
|
|
4482
|
+
return { error: t('non trovate sul sito ({status})', { status: res.status }) };
|
|
4165
4483
|
if (/svg/.test(res.type) || /\.svg$/i.test(res.name))
|
|
4166
|
-
return { error: 'SVG: la libreria non li accetta, perché possono contenere codice' };
|
|
4484
|
+
return { error: t('SVG: la libreria non li accetta, perché possono contenere codice') };
|
|
4167
4485
|
try {
|
|
4168
4486
|
const saved = await files.save(res.name, res.bytes);
|
|
4169
4487
|
const asset = await mediaStore.create({ ...saved, filename: res.name.slice(0, 200), alt, createdBy: user.id });
|
|
@@ -4177,7 +4495,7 @@ When done, reply with one short sentence.`;
|
|
|
4177
4495
|
return media;
|
|
4178
4496
|
}
|
|
4179
4497
|
catch {
|
|
4180
|
-
return { error: 'in un formato che la libreria non legge' };
|
|
4498
|
+
return { error: t('in un formato che la libreria non legge') };
|
|
4181
4499
|
}
|
|
4182
4500
|
},
|
|
4183
4501
|
fetchPage: async (url) => {
|
|
@@ -4394,7 +4712,7 @@ When done, reply with one short sentence.`;
|
|
|
4394
4712
|
// ---------- public delivery API: published content only ----------
|
|
4395
4713
|
const publicDocument = (doc) => {
|
|
4396
4714
|
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
4397
|
-
const data = doc.publishedData ?? {};
|
|
4715
|
+
const data = withoutFormSettings(doc.publishedData ?? {});
|
|
4398
4716
|
return {
|
|
4399
4717
|
id: doc.id,
|
|
4400
4718
|
slug: doc.slug,
|