@materializecms/server 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/admin.js +3 -2
- package/src/ai.js +17 -16
- package/src/app.d.ts +5 -0
- package/src/app.js +518 -264
- package/src/brief.js +3 -2
- package/src/en/api.d.ts +2 -0
- package/src/en/api.js +199 -0
- package/src/en/index.d.ts +5 -0
- package/src/en/index.js +7 -0
- package/src/en/services.d.ts +2 -0
- package/src/en/services.js +154 -0
- package/src/forms.d.ts +6 -3
- package/src/forms.js +22 -11
- package/src/index.d.ts +13 -0
- package/src/index.js +52 -2
- package/src/lang.d.ts +7 -0
- package/src/lang.js +17 -0
- package/src/mail.d.ts +6 -2
- package/src/mail.js +73 -40
- package/src/media.js +3 -2
- package/src/offsite.js +12 -9
- package/src/remote-storage.js +24 -23
- package/src/remote.d.ts +25 -8
- package/src/remote.js +210 -45
- package/src/status-page.js +9 -4
- package/src/t.d.ts +2 -0
- package/src/t.js +4 -0
- package/src/tools.js +28 -27
- package/src/wordpress.js +12 -11
- package/src/wpimport.js +27 -23
package/src/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' }));
|
|
@@ -1704,7 +1898,8 @@ export function createApp(base) {
|
|
|
1704
1898
|
if (input.take && turn && turn.tab !== input.tab) {
|
|
1705
1899
|
// The one who had it learns who took it, at their next beat.
|
|
1706
1900
|
turns.set(`lost:${docId}:${turn.tab}`, { ...turn, takenBy: user.name, at: now });
|
|
1707
|
-
|
|
1901
|
+
const doc = await content.get(docId);
|
|
1902
|
+
await record(c, 'document.turn_taken', doc?.title ?? '', { id: docId, from: turn.name });
|
|
1708
1903
|
}
|
|
1709
1904
|
const keep = turn && turn.userId === user.id && turn.tab === input.tab;
|
|
1710
1905
|
turns.set(docId, { userId: user.id, name: user.name, tab: input.tab, since: keep ? turn.since : now, at: now });
|
|
@@ -1734,9 +1929,9 @@ export function createApp(base) {
|
|
|
1734
1929
|
const user = c.get('user');
|
|
1735
1930
|
const tab = c.req.header('x-mz-tab');
|
|
1736
1931
|
if (turn.userId !== user.id)
|
|
1737
|
-
throw new ApiError(409, 'not_your_turn',
|
|
1932
|
+
throw new ApiError(409, 'not_your_turn', t('{name} sta modificando questo documento: le tue modifiche non sono state salvate', { name: turn.name }));
|
|
1738
1933
|
if (tab && tab !== turn.tab)
|
|
1739
|
-
throw new ApiError(409, 'not_your_turn', 'Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate');
|
|
1934
|
+
throw new ApiError(409, 'not_your_turn', t('Questo documento è aperto in un’altra scheda: le modifiche di qui non sono state salvate'));
|
|
1740
1935
|
};
|
|
1741
1936
|
app.post('/api/documents/:id/presence', requireUser, async (c) => {
|
|
1742
1937
|
const input = await body(c, z.object({ selected: z.string().max(200).nullable() }));
|
|
@@ -1767,26 +1962,37 @@ export function createApp(base) {
|
|
|
1767
1962
|
return false;
|
|
1768
1963
|
}
|
|
1769
1964
|
};
|
|
1770
|
-
/**
|
|
1771
|
-
|
|
1772
|
-
* is known. The request's Host header is used only without it, as in local development: anyone
|
|
1773
|
-
* can send a request with a false Host, and a link to their server would carry the token there.
|
|
1774
|
-
*/
|
|
1775
|
-
const publicOrigin = (c) => {
|
|
1965
|
+
/** The site's own address (MATERIALIZE_SITE_URL, site.url), when it is set. */
|
|
1966
|
+
const siteOrigin = () => {
|
|
1776
1967
|
const site = ctx.config().site.url;
|
|
1777
1968
|
try {
|
|
1778
1969
|
if (site)
|
|
1779
1970
|
return new URL(site).origin;
|
|
1780
1971
|
}
|
|
1781
1972
|
catch { }
|
|
1782
|
-
return
|
|
1973
|
+
return null;
|
|
1783
1974
|
};
|
|
1784
|
-
|
|
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) => {
|
|
1785
1991
|
const mail = await settings.get('mail');
|
|
1786
1992
|
if (!isConfigured(mail))
|
|
1787
1993
|
return false;
|
|
1788
1994
|
try {
|
|
1789
|
-
const url = new URL(path,
|
|
1995
|
+
const url = new URL(path, origin).toString();
|
|
1790
1996
|
await sendMail(mail, { to, subject, text: `${lines.join('\n')}\n\n${url}\n` });
|
|
1791
1997
|
return true;
|
|
1792
1998
|
}
|
|
@@ -1811,7 +2017,9 @@ export function createApp(base) {
|
|
|
1811
2017
|
}),
|
|
1812
2018
|
}));
|
|
1813
2019
|
const before = await settings.get('mail');
|
|
1814
|
-
|
|
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 : '') || '';
|
|
1815
2023
|
const mail = { ...input.mail, pass };
|
|
1816
2024
|
await settings.set('mail', mail, c.get('user').id);
|
|
1817
2025
|
await record(c, 'settings.mail');
|
|
@@ -1825,8 +2033,8 @@ export function createApp(base) {
|
|
|
1825
2033
|
try {
|
|
1826
2034
|
await sendMail(mail, {
|
|
1827
2035
|
to,
|
|
1828
|
-
subject: 'Prova di invio da Materialize',
|
|
1829
|
-
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.'),
|
|
1830
2038
|
});
|
|
1831
2039
|
}
|
|
1832
2040
|
catch (error) {
|
|
@@ -1867,7 +2075,7 @@ export function createApp(base) {
|
|
|
1867
2075
|
const readyAi = async () => {
|
|
1868
2076
|
const ai = await aiSettings();
|
|
1869
2077
|
if (!isReady(ai))
|
|
1870
|
-
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'));
|
|
1871
2079
|
return { ...ai, onUsage: countUsage };
|
|
1872
2080
|
};
|
|
1873
2081
|
const aiFailed = (error) => {
|
|
@@ -1892,7 +2100,9 @@ export function createApp(base) {
|
|
|
1892
2100
|
}),
|
|
1893
2101
|
}));
|
|
1894
2102
|
const before = await aiSettings();
|
|
1895
|
-
|
|
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 : '');
|
|
1896
2106
|
const ai = { ...input.ai, apiKey };
|
|
1897
2107
|
await settings.set('ai', ai, c.get('user').id);
|
|
1898
2108
|
await record(c, 'settings.ai', ai.provider);
|
|
@@ -1931,7 +2141,7 @@ export function createApp(base) {
|
|
|
1931
2141
|
*/
|
|
1932
2142
|
app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
|
|
1933
2143
|
if (!allowAi(c.get('user').id))
|
|
1934
|
-
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'));
|
|
1935
2145
|
const ai = await readyAi();
|
|
1936
2146
|
const config = ctx.config();
|
|
1937
2147
|
const texts = [];
|
|
@@ -1948,7 +2158,7 @@ export function createApp(base) {
|
|
|
1948
2158
|
}
|
|
1949
2159
|
}
|
|
1950
2160
|
if (size < 300)
|
|
1951
|
-
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'));
|
|
1952
2162
|
const reply = await complete(ai, {
|
|
1953
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.',
|
|
1954
2164
|
prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
|
|
@@ -1978,7 +2188,7 @@ ${texts.join('\n---\n')}
|
|
|
1978
2188
|
address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
|
|
1979
2189
|
});
|
|
1980
2190
|
if (!parsed.success)
|
|
1981
|
-
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'));
|
|
1982
2192
|
return c.json({ voice: parsed.data, texts: texts.length });
|
|
1983
2193
|
});
|
|
1984
2194
|
// ---------- AI review before publishing ----------
|
|
@@ -2008,12 +2218,12 @@ ${texts.join('\n---\n')}
|
|
|
2008
2218
|
const existing = await loadDocument(c.req.param('id'));
|
|
2009
2219
|
const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
|
|
2010
2220
|
if (!allowAi(c.get('user').id))
|
|
2011
|
-
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'));
|
|
2012
2222
|
const ai = await readyAi();
|
|
2013
2223
|
const def = collection(existing.collection);
|
|
2014
2224
|
const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
|
|
2015
2225
|
if (!slots.length)
|
|
2016
|
-
throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
|
|
2226
|
+
throw new ApiError(400, 'empty_text', t('Non c’è testo da rivedere'));
|
|
2017
2227
|
const titles = await pageTitles();
|
|
2018
2228
|
const linkTo = (href) => {
|
|
2019
2229
|
const path = href.split('#')[0] || href;
|
|
@@ -2053,7 +2263,7 @@ ${listed.join('\n')}
|
|
|
2053
2263
|
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
2054
2264
|
}
|
|
2055
2265
|
catch {
|
|
2056
|
-
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'));
|
|
2057
2267
|
}
|
|
2058
2268
|
const issue = z.object({
|
|
2059
2269
|
slot: z.number().int().min(0),
|
|
@@ -2099,7 +2309,7 @@ ${listed.join('\n')}
|
|
|
2099
2309
|
? slots[input.slot]
|
|
2100
2310
|
: slots.find((s) => s.text.includes(input.find));
|
|
2101
2311
|
if (!slot)
|
|
2102
|
-
throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
|
|
2312
|
+
throw new ApiError(409, 'changed', t('Quel testo è cambiato: rifai la revisione'));
|
|
2103
2313
|
slot.set(slot.text.replace(input.find, input.replace));
|
|
2104
2314
|
return c.json({ data });
|
|
2105
2315
|
});
|
|
@@ -2129,7 +2339,7 @@ ${listed.join('\n')}
|
|
|
2129
2339
|
max: z.number().int().positive().max(100_000).optional(),
|
|
2130
2340
|
}));
|
|
2131
2341
|
if (!allowAi(c.get('user').id))
|
|
2132
|
-
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'));
|
|
2133
2343
|
const ai = await readyAi();
|
|
2134
2344
|
// Writing from scratch takes the whole voice; changing a text, only its style.
|
|
2135
2345
|
const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
|
|
@@ -2141,7 +2351,7 @@ ${listed.join('\n')}
|
|
|
2141
2351
|
const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
|
|
2142
2352
|
const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
|
|
2143
2353
|
if (!file)
|
|
2144
|
-
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'));
|
|
2145
2355
|
const data = (await readFile(file.path)).toString('base64');
|
|
2146
2356
|
// The text already there may credit the photographer or the source: that part stays.
|
|
2147
2357
|
const current = input.text.trim()
|
|
@@ -2156,7 +2366,7 @@ ${listed.join('\n')}
|
|
|
2156
2366
|
return c.json({ text });
|
|
2157
2367
|
}
|
|
2158
2368
|
if (!input.text.trim())
|
|
2159
|
-
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'));
|
|
2160
2370
|
const task = input.action === 'translate'
|
|
2161
2371
|
? `Translate it into ${input.instruction || 'English'}, naturally, as a native writer would.`
|
|
2162
2372
|
: input.action === 'custom'
|
|
@@ -2220,7 +2430,7 @@ ${listed.join('\n')}
|
|
|
2220
2430
|
}));
|
|
2221
2431
|
const user = c.get('user');
|
|
2222
2432
|
if (!allowAi(user.id))
|
|
2223
|
-
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'));
|
|
2224
2434
|
const ai = await readyAi();
|
|
2225
2435
|
const now = Date.now();
|
|
2226
2436
|
for (const [k, v] of chats)
|
|
@@ -2272,7 +2482,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2272
2482
|
if (limited && !sameDesign(before, page.nodes))
|
|
2273
2483
|
await send({
|
|
2274
2484
|
type: 'error',
|
|
2275
|
-
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.'),
|
|
2276
2486
|
});
|
|
2277
2487
|
else
|
|
2278
2488
|
await send({ type: 'page', nodes: page.nodes });
|
|
@@ -2285,7 +2495,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2285
2495
|
console.error(error);
|
|
2286
2496
|
// A failed turn leaves the conversation as it was before it.
|
|
2287
2497
|
chats.delete(key);
|
|
2288
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2498
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2289
2499
|
await send({ type: 'done', chatId });
|
|
2290
2500
|
}
|
|
2291
2501
|
});
|
|
@@ -2297,7 +2507,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2297
2507
|
app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2298
2508
|
const input = await body(c, z
|
|
2299
2509
|
.object({
|
|
2300
|
-
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(),
|
|
2301
2511
|
/** A reply in a run already started, with its id. */
|
|
2302
2512
|
runId: z.string().max(64).optional(),
|
|
2303
2513
|
message: z.string().trim().min(1).max(4000).optional(),
|
|
@@ -2310,10 +2520,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2310
2520
|
.max(2)
|
|
2311
2521
|
.default([]),
|
|
2312
2522
|
})
|
|
2313
|
-
.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') }));
|
|
2314
2524
|
const user = c.get('user');
|
|
2315
2525
|
if (!allowAi(user.id))
|
|
2316
|
-
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'));
|
|
2317
2527
|
const ai = await readyAi();
|
|
2318
2528
|
const now = Date.now();
|
|
2319
2529
|
for (const [k, v] of briefRuns)
|
|
@@ -2323,12 +2533,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2323
2533
|
const key = `${user.id}:${runId}`;
|
|
2324
2534
|
let run = input.runId ? briefRuns.get(key) : undefined;
|
|
2325
2535
|
if (input.runId && (!run || run.conv.provider !== ai.provider))
|
|
2326
|
-
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'));
|
|
2327
2537
|
if (!run) {
|
|
2328
2538
|
const config = ctx.config();
|
|
2329
2539
|
const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
|
|
2330
2540
|
if (!pageCollections.length)
|
|
2331
|
-
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'));
|
|
2332
2542
|
const locale = config.locales.default;
|
|
2333
2543
|
const existing = [];
|
|
2334
2544
|
for (const d of config.collections.filter((x) => !x.singleton)) {
|
|
@@ -2398,7 +2608,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2398
2608
|
// What was made before the failure stays, as drafts: the panel lists it. The run ends.
|
|
2399
2609
|
briefRuns.delete(key);
|
|
2400
2610
|
await send({ type: 'result', ...result });
|
|
2401
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2611
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2402
2612
|
await send({ type: 'done' });
|
|
2403
2613
|
}
|
|
2404
2614
|
});
|
|
@@ -2419,7 +2629,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2419
2629
|
}));
|
|
2420
2630
|
const user = c.get('user');
|
|
2421
2631
|
if (!allowAi(user.id))
|
|
2422
|
-
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'));
|
|
2423
2633
|
const ai = await readyAi();
|
|
2424
2634
|
const now = Date.now();
|
|
2425
2635
|
for (const [k, v] of studioRuns)
|
|
@@ -2474,7 +2684,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2474
2684
|
if (!(error instanceof AiError))
|
|
2475
2685
|
console.error(error);
|
|
2476
2686
|
studioRuns.delete(key);
|
|
2477
|
-
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2687
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : t('Errore') });
|
|
2478
2688
|
await send({ type: 'done' });
|
|
2479
2689
|
}
|
|
2480
2690
|
});
|
|
@@ -2531,8 +2741,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2531
2741
|
}), async (c) => {
|
|
2532
2742
|
const form = c.req.param('form').slice(0, 80);
|
|
2533
2743
|
const ip = clientIp(c);
|
|
2534
|
-
|
|
2535
|
-
|
|
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();
|
|
2536
2747
|
const type = c.req.header('content-type') ?? '';
|
|
2537
2748
|
const isJson = type.includes('application/json');
|
|
2538
2749
|
const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
|
|
@@ -2543,6 +2754,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2543
2754
|
if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
|
|
2544
2755
|
return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
|
|
2545
2756
|
const def = (await publishedForms()).get(form) ?? null;
|
|
2757
|
+
if (!def && !allowHandForm(ip))
|
|
2758
|
+
throw tooMany();
|
|
2546
2759
|
const { data, files, issues } = await readSubmission(raw, def);
|
|
2547
2760
|
if (issues.length) {
|
|
2548
2761
|
if (!wantsJson)
|
|
@@ -2554,7 +2767,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2554
2767
|
const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
|
|
2555
2768
|
const fingerprint = fingerprintOf(form, data);
|
|
2556
2769
|
const spam = spamReason(data, elapsed) ??
|
|
2557
|
-
((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);
|
|
2558
2771
|
// A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
|
|
2559
2772
|
if (def?.newsletter) {
|
|
2560
2773
|
if (!spam)
|
|
@@ -2563,7 +2776,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2563
2776
|
}
|
|
2564
2777
|
const formName = def?.name?.trim() || def?.subject?.trim() || '';
|
|
2565
2778
|
const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
|
|
2566
|
-
|
|
2779
|
+
// Spam keeps its words, for a check, but not its files: they would only fill the disk.
|
|
2780
|
+
if (files.length && !spam)
|
|
2567
2781
|
await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
|
|
2568
2782
|
// Spam stays in the panel, recoverable, but nobody is notified.
|
|
2569
2783
|
if (!spam) {
|
|
@@ -2572,13 +2786,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2572
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];
|
|
2573
2787
|
if (isConfigured(mail) && to) {
|
|
2574
2788
|
const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
: ''
|
|
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
|
+
}));
|
|
2578
2797
|
sendMail(mail, {
|
|
2579
2798
|
to,
|
|
2580
|
-
|
|
2581
|
-
text: submissionText(page, data) + attached,
|
|
2799
|
+
...notice,
|
|
2582
2800
|
replyTo: sender,
|
|
2583
2801
|
})
|
|
2584
2802
|
.then(() => submissions.markMailed(saved.id))
|
|
@@ -2602,7 +2820,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2602
2820
|
createdAt: saved.createdAt.toISOString(),
|
|
2603
2821
|
};
|
|
2604
2822
|
void webhookSecret()
|
|
2605
|
-
.then((secret) => sendWebhook(def.webhook, payload, secret))
|
|
2823
|
+
.then((secret) => sendWebhook(def.webhook, payload, secret, undefined, privateWebhooks))
|
|
2606
2824
|
.then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
|
|
2607
2825
|
.catch((error) => console.error('Materialize: webhook not sent', error));
|
|
2608
2826
|
}
|
|
@@ -2618,8 +2836,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2618
2836
|
app.get('/api/submissions', requireUser, async (c) => {
|
|
2619
2837
|
const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
|
|
2620
2838
|
const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
|
|
2839
|
+
const list = await submissions.list(limit, offset, filterOf(c));
|
|
2621
2840
|
return c.json({
|
|
2622
|
-
...
|
|
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) })),
|
|
2623
2844
|
forms: await submissions.forms(),
|
|
2624
2845
|
});
|
|
2625
2846
|
});
|
|
@@ -2633,7 +2854,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2633
2854
|
return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
2634
2855
|
};
|
|
2635
2856
|
const rows = [
|
|
2636
|
-
['Data', 'Modulo', 'Pagina', ...fields],
|
|
2857
|
+
[t('Data'), t('Modulo'), t('Pagina'), ...fields],
|
|
2637
2858
|
...items.map((m) => [
|
|
2638
2859
|
m.createdAt.toISOString(),
|
|
2639
2860
|
m.formName || m.form,
|
|
@@ -2657,7 +2878,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2657
2878
|
if (input.read !== undefined)
|
|
2658
2879
|
await submissions.markRead(id, input.read);
|
|
2659
2880
|
if (input.spam !== undefined)
|
|
2660
|
-
await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
|
|
2881
|
+
await submissions.markSpam(id, input.spam ? t.keep('Segnato a mano') : null);
|
|
2661
2882
|
return c.json({ ok: true });
|
|
2662
2883
|
});
|
|
2663
2884
|
/** A file sent with a message: always downloaded, never opened in the panel's own page. */
|
|
@@ -2665,10 +2886,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2665
2886
|
const message = await submissions.get(c.req.param('id'));
|
|
2666
2887
|
const file = message?.files[Number(c.req.param('n'))];
|
|
2667
2888
|
if (!message || !file)
|
|
2668
|
-
throw new ApiError(404, 'not_found', 'File non trovato');
|
|
2889
|
+
throw new ApiError(404, 'not_found', t('File non trovato'));
|
|
2669
2890
|
const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
|
|
2670
2891
|
if (!bytes)
|
|
2671
|
-
throw new ApiError(404, 'not_found', 'Il file non c’è più');
|
|
2892
|
+
throw new ApiError(404, 'not_found', t('Il file non c’è più'));
|
|
2672
2893
|
return new Response(bytes, {
|
|
2673
2894
|
headers: {
|
|
2674
2895
|
'content-type': 'application/octet-stream',
|
|
@@ -2736,14 +2957,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2736
2957
|
const category = z.enum(['necessary', 'analytics', 'marketing']);
|
|
2737
2958
|
const input = await body(c, z.object({
|
|
2738
2959
|
tracking: z.object({
|
|
2739
|
-
ga4: id(TRACKING_PATTERNS.ga4, 'Un ID di Google Analytics 4 inizia con G-'),
|
|
2740
|
-
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-')),
|
|
2741
2962
|
gtmCategory: category.optional(),
|
|
2742
|
-
metaPixel: id(TRACKING_PATTERNS.metaPixel, 'L’ID del Pixel è fatto di sole cifre'),
|
|
2743
|
-
plausible: id(TRACKING_PATTERNS.plausible, 'Scrivi il dominio come su Plausible, per esempio example.com'),
|
|
2744
|
-
plausibleSrc: id(/^https:\/\/[^"<>\s]+$/, 'Un indirizzo https://'),
|
|
2745
|
-
matomoUrl: id(/^https:\/\/[^"'<>\s]+$/, 'L’indirizzo di Matomo, con https://'),
|
|
2746
|
-
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')),
|
|
2747
2968
|
custom: z
|
|
2748
2969
|
.array(z.object({
|
|
2749
2970
|
id: z.string().min(1).max(40),
|
|
@@ -2753,7 +2974,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2753
2974
|
code: z
|
|
2754
2975
|
.string()
|
|
2755
2976
|
.max(20_000)
|
|
2756
|
-
.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>')),
|
|
2757
2978
|
}))
|
|
2758
2979
|
.max(30)
|
|
2759
2980
|
.optional(),
|
|
@@ -2761,7 +2982,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2761
2982
|
.object({
|
|
2762
2983
|
title: z.string().trim().max(120).optional(),
|
|
2763
2984
|
text: z.string().trim().max(600).optional(),
|
|
2764
|
-
policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, 'Un indirizzo che inizia con / o https://'),
|
|
2985
|
+
policyUrl: id(/^(https?:\/\/|\/)[^"<>\s]*$/, t('Un indirizzo che inizia con / o https://')),
|
|
2765
2986
|
position: z.enum(['bottom', 'corner']).optional(),
|
|
2766
2987
|
reopenButton: z.boolean().optional(),
|
|
2767
2988
|
})
|
|
@@ -3023,18 +3244,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3023
3244
|
}
|
|
3024
3245
|
const at = new Date(input.at);
|
|
3025
3246
|
if (at.getTime() <= Date.now() + 30_000)
|
|
3026
|
-
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'));
|
|
3027
3248
|
const current = await schedules.forDocument(existing.id);
|
|
3028
3249
|
if (input.action === 'publish')
|
|
3029
3250
|
publishableData(collection(existing.collection), existing.data);
|
|
3030
3251
|
else {
|
|
3031
3252
|
if (!existing.publishedAt && !current.publish)
|
|
3032
|
-
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'));
|
|
3033
3254
|
if (current.publish && current.publish.runAt >= at)
|
|
3034
|
-
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'));
|
|
3035
3256
|
}
|
|
3036
3257
|
if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
|
|
3037
|
-
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'));
|
|
3038
3259
|
await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
|
|
3039
3260
|
await record(c, 'document.scheduled', existing.title, {
|
|
3040
3261
|
collection: existing.collection,
|
|
@@ -3048,12 +3269,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3048
3269
|
const publishProblem = (doc) => {
|
|
3049
3270
|
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
3050
3271
|
if (!def)
|
|
3051
|
-
return 'La raccolta non esiste più';
|
|
3272
|
+
return t.keep('La raccolta non esiste più');
|
|
3052
3273
|
const check = validate(def, prepare(def, doc.data));
|
|
3053
3274
|
if (check.ok)
|
|
3054
3275
|
return null;
|
|
3055
3276
|
const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
|
|
3056
|
-
return
|
|
3277
|
+
return fields.length
|
|
3278
|
+
? t.keep('Alcuni campi vanno completati: {fields}', { fields: fields.join(', ') })
|
|
3279
|
+
: t.keep('Alcuni campi vanno completati');
|
|
3057
3280
|
};
|
|
3058
3281
|
/**
|
|
3059
3282
|
* Runs the actions whose time has come, as the person who set them. A server that was off at the
|
|
@@ -3080,7 +3303,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3080
3303
|
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3081
3304
|
const existing = await content.get(item.documentId);
|
|
3082
3305
|
if (!existing) {
|
|
3083
|
-
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ù'));
|
|
3084
3307
|
continue;
|
|
3085
3308
|
}
|
|
3086
3309
|
try {
|
|
@@ -3100,7 +3323,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3100
3323
|
ran++;
|
|
3101
3324
|
}
|
|
3102
3325
|
catch (error) {
|
|
3103
|
-
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');
|
|
3104
3327
|
await schedules.finish(item.id, 'failed', message);
|
|
3105
3328
|
await note('document.schedule_failed', existing.title, {
|
|
3106
3329
|
collection: existing.collection,
|
|
@@ -3146,7 +3369,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3146
3369
|
for (const item of release.items) {
|
|
3147
3370
|
const doc = await content.get(item.documentId);
|
|
3148
3371
|
if (!doc)
|
|
3149
|
-
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ù') });
|
|
3150
3373
|
else if (item.action === 'publish') {
|
|
3151
3374
|
const message = publishProblem(doc);
|
|
3152
3375
|
if (message)
|
|
@@ -3154,7 +3377,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3154
3377
|
}
|
|
3155
3378
|
}
|
|
3156
3379
|
if (!release.items.length)
|
|
3157
|
-
out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
|
|
3380
|
+
out.push({ documentId: '', title: '', message: t.keep('Il rilascio è vuoto') });
|
|
3158
3381
|
return out;
|
|
3159
3382
|
};
|
|
3160
3383
|
/**
|
|
@@ -3167,7 +3390,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3167
3390
|
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3168
3391
|
const problems = await releaseProblems(release);
|
|
3169
3392
|
if (problems.length) {
|
|
3170
|
-
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
|
+
});
|
|
3171
3396
|
await releases.finish(release.id, 'failed', message);
|
|
3172
3397
|
await note('release.failed', release.name, { id: release.id, message });
|
|
3173
3398
|
return false;
|
|
@@ -3190,8 +3415,8 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3190
3415
|
}
|
|
3191
3416
|
}
|
|
3192
3417
|
catch (error) {
|
|
3193
|
-
const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
|
|
3194
|
-
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;
|
|
3195
3420
|
await releases.finish(release.id, 'failed', message);
|
|
3196
3421
|
await note('release.failed', release.name, { id: release.id, message });
|
|
3197
3422
|
return false;
|
|
@@ -3214,21 +3439,21 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3214
3439
|
collection: doc.collection,
|
|
3215
3440
|
locale: doc.locale,
|
|
3216
3441
|
status: doc.status,
|
|
3217
|
-
problem: problems.find((p) => p.documentId === doc.id)?.message
|
|
3442
|
+
problem: t.read(problems.find((p) => p.documentId === doc.id)?.message) || null,
|
|
3218
3443
|
});
|
|
3219
3444
|
}
|
|
3220
|
-
return { ...release, items, ready: problems.length === 0 };
|
|
3445
|
+
return { ...release, error: release.error && t.read(release.error), items, ready: problems.length === 0 };
|
|
3221
3446
|
};
|
|
3222
3447
|
const loadRelease = async (id) => {
|
|
3223
3448
|
const release = await releases.get(id);
|
|
3224
3449
|
if (!release)
|
|
3225
|
-
throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
|
|
3450
|
+
throw new ApiError(404, 'not_found', t('Questo rilascio non esiste'));
|
|
3226
3451
|
return release;
|
|
3227
3452
|
};
|
|
3228
3453
|
const openRelease = async (id) => {
|
|
3229
3454
|
const release = await loadRelease(id);
|
|
3230
3455
|
if (release.status === 'published' || release.status === 'cancelled')
|
|
3231
|
-
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'));
|
|
3232
3457
|
return release;
|
|
3233
3458
|
};
|
|
3234
3459
|
app.get('/api/releases', requireUser, async (c) => {
|
|
@@ -3264,7 +3489,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3264
3489
|
const doc = await loadDocument(c.req.param('documentId'));
|
|
3265
3490
|
const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
|
|
3266
3491
|
if (input.action === 'unpublish' && !doc.publishedAt)
|
|
3267
|
-
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'));
|
|
3268
3493
|
await releases.put(release.id, doc.id, input.action);
|
|
3269
3494
|
await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
|
|
3270
3495
|
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
@@ -3282,15 +3507,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3282
3507
|
return c.json({
|
|
3283
3508
|
error: {
|
|
3284
3509
|
code: 'release_not_ready',
|
|
3285
|
-
message: 'Alcune pagine non sono pronte: non è uscito niente',
|
|
3286
|
-
problems,
|
|
3510
|
+
message: t('Alcune pagine non sono pronte: non è uscito niente'),
|
|
3511
|
+
problems: problems.map((p) => ({ ...p, message: t.read(p.message) })),
|
|
3287
3512
|
},
|
|
3288
3513
|
}, 422);
|
|
3289
3514
|
const user = c.get('user');
|
|
3290
3515
|
const ok = await releaseOut(release, user);
|
|
3291
3516
|
const after = await loadRelease(release.id);
|
|
3292
3517
|
if (!ok)
|
|
3293
|
-
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'));
|
|
3294
3519
|
return c.json(await releaseJson(after));
|
|
3295
3520
|
});
|
|
3296
3521
|
/** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
|
|
@@ -3305,9 +3530,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3305
3530
|
}
|
|
3306
3531
|
const at = new Date(input.at);
|
|
3307
3532
|
if (at.getTime() <= Date.now() + 30_000)
|
|
3308
|
-
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'));
|
|
3309
3534
|
if (!release.items.length)
|
|
3310
|
-
throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
|
|
3535
|
+
throw new ApiError(400, 'empty_release', t('Aggiungi prima qualcosa al rilascio'));
|
|
3311
3536
|
await releases.schedule(release.id, at, user);
|
|
3312
3537
|
await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
|
|
3313
3538
|
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
@@ -3476,7 +3701,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3476
3701
|
app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
|
|
3477
3702
|
const found = await newsletter.subscriber(c.req.param('id'));
|
|
3478
3703
|
if (!found)
|
|
3479
|
-
throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
|
|
3704
|
+
throw new ApiError(404, 'not_found', t('Questo iscritto non esiste'));
|
|
3480
3705
|
await newsletter.removeSubscriber(found.id);
|
|
3481
3706
|
await record(c, 'newsletter.subscriber_removed', found.email);
|
|
3482
3707
|
return c.body(null, 204);
|
|
@@ -3485,7 +3710,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3485
3710
|
const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
|
|
3486
3711
|
const rows = parseSubscribersCsv(input.csv);
|
|
3487
3712
|
if (!rows.length)
|
|
3488
|
-
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'));
|
|
3489
3714
|
const result = await newsletter.importMany(rows);
|
|
3490
3715
|
await record(c, 'newsletter.imported', '', result);
|
|
3491
3716
|
return c.json({ found: rows.length, ...result });
|
|
@@ -3524,16 +3749,20 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3524
3749
|
return c.json(input);
|
|
3525
3750
|
});
|
|
3526
3751
|
// ----- panel: newsletters -----
|
|
3527
|
-
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
|
+
});
|
|
3528
3757
|
const loadNewsletter = async (id) => {
|
|
3529
3758
|
const found = await newsletter.get(id);
|
|
3530
3759
|
if (!found)
|
|
3531
|
-
throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
|
|
3760
|
+
throw new ApiError(404, 'not_found', t('Questa newsletter non esiste'));
|
|
3532
3761
|
return found;
|
|
3533
3762
|
};
|
|
3534
3763
|
const editable = (n) => {
|
|
3535
3764
|
if (n.status === 'sending' || n.status === 'sent')
|
|
3536
|
-
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'));
|
|
3537
3766
|
};
|
|
3538
3767
|
const newsletterBody = z.object({
|
|
3539
3768
|
subject: z.string().trim().min(1).max(200),
|
|
@@ -3558,7 +3787,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3558
3787
|
app.delete('/api/newsletters/:id', requireUser, async (c) => {
|
|
3559
3788
|
const found = await loadNewsletter(c.req.param('id'));
|
|
3560
3789
|
if (found.status === 'sending')
|
|
3561
|
-
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'));
|
|
3562
3791
|
await newsletter.remove(found.id);
|
|
3563
3792
|
await record(c, 'newsletter.deleted', found.subject);
|
|
3564
3793
|
return c.body(null, 204);
|
|
@@ -3601,17 +3830,25 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3601
3830
|
const readyMail = async () => {
|
|
3602
3831
|
const mail = await settings.get('mail');
|
|
3603
3832
|
if (!isConfigured(mail))
|
|
3604
|
-
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'));
|
|
3605
3834
|
return mail;
|
|
3606
3835
|
};
|
|
3607
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);
|
|
3608
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'));
|
|
3609
3842
|
const found = await loadNewsletter(c.req.param('id'));
|
|
3610
3843
|
const input = await body(c, z.object({ to: z.email().optional() }));
|
|
3611
3844
|
const mail = await readyMail();
|
|
3612
3845
|
await newsletterOrigin(c);
|
|
3613
3846
|
const message = await composeNewsletter(found, null);
|
|
3614
|
-
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
|
+
});
|
|
3615
3852
|
return c.json({ ok: true });
|
|
3616
3853
|
});
|
|
3617
3854
|
app.post('/api/newsletters/:id/send', requireUser, async (c) => {
|
|
@@ -3621,7 +3858,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3621
3858
|
await newsletterOrigin(c);
|
|
3622
3859
|
const { counts } = await newsletter.subscribers({ limit: 1 });
|
|
3623
3860
|
if (!counts.active)
|
|
3624
|
-
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'));
|
|
3625
3862
|
await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
|
|
3626
3863
|
await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
|
|
3627
3864
|
void runNewsletters();
|
|
@@ -3637,7 +3874,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3637
3874
|
}
|
|
3638
3875
|
const at = new Date(input.at);
|
|
3639
3876
|
if (at.getTime() <= Date.now() + 30_000)
|
|
3640
|
-
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'));
|
|
3641
3878
|
await readyMail();
|
|
3642
3879
|
await newsletterOrigin(c);
|
|
3643
3880
|
await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
|
|
@@ -3687,7 +3924,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3687
3924
|
const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
|
|
3688
3925
|
const user = c.get('user');
|
|
3689
3926
|
if (!allowAi(user.id))
|
|
3690
|
-
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'));
|
|
3691
3928
|
const ai = await readyAi();
|
|
3692
3929
|
const config = ctx.config();
|
|
3693
3930
|
const lang = config.locales.default;
|
|
@@ -3717,7 +3954,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3717
3954
|
items.sort((a, b) => b.date - a.date);
|
|
3718
3955
|
const latest = items.slice(0, input.count);
|
|
3719
3956
|
if (!latest.length)
|
|
3720
|
-
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'));
|
|
3721
3958
|
const voice = await voiceText();
|
|
3722
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": "..."}?}]}.`;
|
|
3723
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')}`;
|
|
@@ -3727,7 +3964,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3727
3964
|
parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
|
|
3728
3965
|
}
|
|
3729
3966
|
catch {
|
|
3730
|
-
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'));
|
|
3731
3968
|
}
|
|
3732
3969
|
const known = new Set(latest.map((a) => a.url));
|
|
3733
3970
|
const blocks = [];
|
|
@@ -3763,7 +4000,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3763
4000
|
for (const n of await newsletter.due(now)) {
|
|
3764
4001
|
const mail = await settings.get('mail');
|
|
3765
4002
|
if (!isConfigured(mail)) {
|
|
3766
|
-
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') });
|
|
3767
4004
|
continue;
|
|
3768
4005
|
}
|
|
3769
4006
|
if (n.status === 'scheduled')
|
|
@@ -3776,7 +4013,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3776
4013
|
for (const item of batch) {
|
|
3777
4014
|
const who = await newsletter.subscriber(item.subscriberId);
|
|
3778
4015
|
if (!who || who.status !== 'active') {
|
|
3779
|
-
await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
|
|
4016
|
+
await newsletter.markSend(item.id, 'failed', t.keep('Non più iscritto'));
|
|
3780
4017
|
continue;
|
|
3781
4018
|
}
|
|
3782
4019
|
try {
|
|
@@ -3786,7 +4023,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3786
4023
|
sent++;
|
|
3787
4024
|
}
|
|
3788
4025
|
catch (error) {
|
|
3789
|
-
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'));
|
|
3790
4027
|
}
|
|
3791
4028
|
if (pause)
|
|
3792
4029
|
await new Promise((r) => setTimeout(r, pause));
|
|
@@ -3796,7 +4033,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3796
4033
|
const allFailed = stats.total > 0 && stats.sent === 0;
|
|
3797
4034
|
await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
|
|
3798
4035
|
sentAt: new Date(),
|
|
3799
|
-
error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
|
|
4036
|
+
error: allFailed ? t.keep('Nessun invio riuscito: controlla Impostazioni → Email') : null,
|
|
3800
4037
|
});
|
|
3801
4038
|
}
|
|
3802
4039
|
// Signups never confirmed are dropped after a week.
|
|
@@ -3829,9 +4066,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3829
4066
|
const existing = await loadDocument(c.req.param('id'));
|
|
3830
4067
|
const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
|
|
3831
4068
|
if (!ctx.preview)
|
|
3832
|
-
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'));
|
|
3833
4070
|
if (!collection(existing.collection).url)
|
|
3834
|
-
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'));
|
|
3835
4072
|
const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
|
|
3836
4073
|
const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
|
|
3837
4074
|
await record(c, 'document.preview_shared', existing.title, {
|
|
@@ -3844,7 +4081,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3844
4081
|
app.delete('/api/previews/:id', requireUser, async (c) => {
|
|
3845
4082
|
const link = await previewLinks.get(c.req.param('id'));
|
|
3846
4083
|
if (!link)
|
|
3847
|
-
throw new ApiError(404, 'not_found', 'Link non trovato');
|
|
4084
|
+
throw new ApiError(404, 'not_found', t('Link non trovato'));
|
|
3848
4085
|
const doc = await content.get(link.documentId);
|
|
3849
4086
|
await previewLinks.revoke(link.id);
|
|
3850
4087
|
await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
|
|
@@ -3875,6 +4112,15 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3875
4112
|
app.post('/api/documents/:id/revisions/:revision/restore', requireUser, async (c) => {
|
|
3876
4113
|
const existing = await loadDocument(c.req.param('id'));
|
|
3877
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]);
|
|
3878
4124
|
const doc = await content.restore(existing.id, c.req.param('revision'), c.get('user').id);
|
|
3879
4125
|
if (!doc)
|
|
3880
4126
|
throw new ApiError(404, 'not_found', 'This version does not exist');
|
|
@@ -3896,11 +4142,14 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3896
4142
|
});
|
|
3897
4143
|
app.put('/api/locales', requireUser, requireSession, requireAdmin, async (c) => {
|
|
3898
4144
|
const input = await body(c, z.object({
|
|
3899
|
-
default: z.string().refine(isLocaleCode, 'Codice lingua non valido'),
|
|
3900
|
-
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),
|
|
3901
4150
|
}));
|
|
3902
4151
|
if (!input.available.includes(input.default))
|
|
3903
|
-
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'));
|
|
3904
4153
|
const value = { default: input.default, available: [...new Set(input.available)] };
|
|
3905
4154
|
await settings.set('locales', value, c.get('user').id);
|
|
3906
4155
|
languages = value;
|
|
@@ -3938,11 +4187,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3938
4187
|
const lang = locale(input.locale);
|
|
3939
4188
|
const group = await content.translations(doc.id);
|
|
3940
4189
|
if (group.some((t) => t.locale === lang))
|
|
3941
|
-
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'));
|
|
3942
4191
|
if (def.singleton && (await content.list(def.name, { locale: lang, limit: 1 })).total > 0)
|
|
3943
|
-
throw new ApiError(409, 'singleton_exists',
|
|
4192
|
+
throw new ApiError(409, 'singleton_exists', t('"{label}" esiste già in questa lingua', { label: def.label }));
|
|
3944
4193
|
if (input.translate && !allowAi(c.get('user').id))
|
|
3945
|
-
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'));
|
|
3946
4195
|
const data = structuredClone(doc.data);
|
|
3947
4196
|
if (input.translate) {
|
|
3948
4197
|
// The address is translated too ("chi-siamo" → "about-us"), the home page keeps its own.
|
|
@@ -3977,7 +4226,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3977
4226
|
const input = await body(c, z.object({ translate: z.boolean().default(false) }));
|
|
3978
4227
|
const doc = await loadDocument(c.req.param('id'));
|
|
3979
4228
|
if (!doc.sourceId)
|
|
3980
|
-
throw new ApiError(400, 'not_a_translation', 'Questa è la versione originale');
|
|
4229
|
+
throw new ApiError(400, 'not_a_translation', t('Questa è la versione originale'));
|
|
3981
4230
|
const original = await loadDocument(doc.sourceId);
|
|
3982
4231
|
const def = collection(doc.collection);
|
|
3983
4232
|
const data = structuredClone(doc.data);
|
|
@@ -3993,7 +4242,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3993
4242
|
}
|
|
3994
4243
|
if (input.translate && fresh.size) {
|
|
3995
4244
|
if (!allowAi(c.get('user').id))
|
|
3996
|
-
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'));
|
|
3997
4246
|
const ai = await readyAi();
|
|
3998
4247
|
for (const [name, f] of Object.entries(def.fields)) {
|
|
3999
4248
|
if (f.kind !== 'page' || !Array.isArray(data[name]))
|
|
@@ -4031,7 +4280,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
4031
4280
|
wpJobs.delete(k);
|
|
4032
4281
|
};
|
|
4033
4282
|
/** A WordPress site is read from its address (REST API) or from its export file. */
|
|
4034
|
-
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) => {
|
|
4035
4289
|
forget();
|
|
4036
4290
|
const isForm = (c.req.header('content-type') ?? '').includes('multipart/form-data');
|
|
4037
4291
|
let site;
|
|
@@ -4065,7 +4319,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
4065
4319
|
if (error instanceof WordPressError)
|
|
4066
4320
|
throw new ApiError(422, 'wordpress_unreadable', error.message);
|
|
4067
4321
|
if (error instanceof RemoteError)
|
|
4068
|
-
throw new ApiError(422, 'wordpress_unreadable',
|
|
4322
|
+
throw new ApiError(422, 'wordpress_unreadable', t('Il sito non risponde: {message}', { message: error.message }));
|
|
4069
4323
|
throw error;
|
|
4070
4324
|
}
|
|
4071
4325
|
const token = randomBytes(16).toString('hex');
|
|
@@ -4122,12 +4376,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
4122
4376
|
const relayoutPage = async (ai, id, user, credentials) => {
|
|
4123
4377
|
const doc = await content.get(id);
|
|
4124
4378
|
if (!doc)
|
|
4125
|
-
return 'la pagina non c’è più';
|
|
4379
|
+
return t('la pagina non c’è più');
|
|
4126
4380
|
const def = collection(doc.collection);
|
|
4127
4381
|
const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
|
|
4128
4382
|
const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
|
|
4129
4383
|
if (!field || !before?.length)
|
|
4130
|
-
return 'la pagina è vuota';
|
|
4384
|
+
return t('la pagina è vuota');
|
|
4131
4385
|
const page = { nodes: before };
|
|
4132
4386
|
const call = apiCaller(app.fetch, credentials);
|
|
4133
4387
|
const getSchema = () => call('GET', '/api/schema');
|
|
@@ -4219,15 +4473,15 @@ When done, reply with one short sentence.`;
|
|
|
4219
4473
|
}
|
|
4220
4474
|
catch (error) {
|
|
4221
4475
|
return {
|
|
4222
|
-
error: error instanceof RemoteError &&
|
|
4223
|
-
? 'troppo grandi (oltre 25 MB)'
|
|
4224
|
-
: 'non raggiungibili',
|
|
4476
|
+
error: error instanceof RemoteError && error.reason === 'too_large'
|
|
4477
|
+
? t('troppo grandi (oltre 25 MB)')
|
|
4478
|
+
: t('non raggiungibili'),
|
|
4225
4479
|
};
|
|
4226
4480
|
}
|
|
4227
4481
|
if (res.status !== 200)
|
|
4228
|
-
return { error:
|
|
4482
|
+
return { error: t('non trovate sul sito ({status})', { status: res.status }) };
|
|
4229
4483
|
if (/svg/.test(res.type) || /\.svg$/i.test(res.name))
|
|
4230
|
-
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') };
|
|
4231
4485
|
try {
|
|
4232
4486
|
const saved = await files.save(res.name, res.bytes);
|
|
4233
4487
|
const asset = await mediaStore.create({ ...saved, filename: res.name.slice(0, 200), alt, createdBy: user.id });
|
|
@@ -4241,7 +4495,7 @@ When done, reply with one short sentence.`;
|
|
|
4241
4495
|
return media;
|
|
4242
4496
|
}
|
|
4243
4497
|
catch {
|
|
4244
|
-
return { error: 'in un formato che la libreria non legge' };
|
|
4498
|
+
return { error: t('in un formato che la libreria non legge') };
|
|
4245
4499
|
}
|
|
4246
4500
|
},
|
|
4247
4501
|
fetchPage: async (url) => {
|
|
@@ -4458,7 +4712,7 @@ When done, reply with one short sentence.`;
|
|
|
4458
4712
|
// ---------- public delivery API: published content only ----------
|
|
4459
4713
|
const publicDocument = (doc) => {
|
|
4460
4714
|
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
4461
|
-
const data = doc.publishedData ?? {};
|
|
4715
|
+
const data = withoutFormSettings(doc.publishedData ?? {});
|
|
4462
4716
|
return {
|
|
4463
4717
|
id: doc.id,
|
|
4464
4718
|
slug: doc.slug,
|