@beechcms/api 0.4.0-preview.1
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/README.md +21 -0
- package/migrations/0000_v040_base.sql +213 -0
- package/package.json +36 -0
- package/src/auth/constants.ts +10 -0
- package/src/auth/login.ts +91 -0
- package/src/auth/refresh.ts +127 -0
- package/src/content.ts +502 -0
- package/src/factory.ts +56 -0
- package/src/features/draft/draft.handler.ts +198 -0
- package/src/features/draft/draft.test.ts +315 -0
- package/src/features/draft/index.ts +1 -0
- package/src/features/email/email.provider.ts +38 -0
- package/src/features/email/email.service.ts +80 -0
- package/src/features/email/email.types.ts +98 -0
- package/src/features/email/index.ts +28 -0
- package/src/features/email/providers/resend.ts +63 -0
- package/src/features/email/templates/password-changed.ts +59 -0
- package/src/features/email/templates/password-reset.ts +64 -0
- package/src/features/email/templates/shell.ts +93 -0
- package/src/features/notifications/index.ts +1 -0
- package/src/features/notifications/notifications.handler.ts +88 -0
- package/src/features/password-reset/index.ts +15 -0
- package/src/features/password-reset/request.ts +88 -0
- package/src/features/password-reset/reset.ts +110 -0
- package/src/features/rotate-field/index.ts +1 -0
- package/src/features/rotate-field/rotate-field.handler.ts +82 -0
- package/src/features/rotate-field/rotate-field.schema.ts +9 -0
- package/src/features/rotate-field/rotate-field.test.ts +297 -0
- package/src/features/settings/settings.handler.ts +249 -0
- package/src/features/setup/index.ts +59 -0
- package/src/features/stats/index.ts +1 -0
- package/src/features/stats/stats.handler.ts +395 -0
- package/src/index.ts +344 -0
- package/src/media-utils.ts +78 -0
- package/src/middleware.ts +67 -0
- package/src/public/access-policy.ts +23 -0
- package/src/public/api-key-middleware.ts +53 -0
- package/src/public/index.ts +12 -0
- package/src/public/problem-details.ts +42 -0
- package/src/public/public-add.ts +183 -0
- package/src/public/public-edit.ts +183 -0
- package/src/public/public-errors.ts +15 -0
- package/src/public/public-read.ts +217 -0
- package/src/public/public-routes.ts +31 -0
- package/src/public/query-builder.ts +241 -0
- package/src/public/rate-limit-middleware.ts +42 -0
- package/src/public/response-builder.ts +26 -0
- package/src/public/sanitize.ts +65 -0
- package/src/public/slug-utils.ts +14 -0
- package/src/search-utils.ts +188 -0
- package/src/search.ts +72 -0
- package/src/shared/activity-logger.ts +79 -0
- package/src/shared/apply-policies.ts +63 -0
- package/src/shared/content-utils.ts +108 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/notification-service.ts +56 -0
- package/src/shared/query-utils.ts +137 -0
- package/src/shared/storage-utils.ts +36 -0
- package/src/types.ts +35 -0
- package/src/upload.ts +335 -0
- package/src/widget.ts +349 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipi condivisi del modulo email di Beech CMS.
|
|
3
|
+
*
|
|
4
|
+
* Tutti i tipi usati tra provider, service e template sono definiti qui
|
|
5
|
+
* in modo che ogni layer rimanga disaccoppiato dagli altri.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Locale ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Lingue supportate dal sistema di template email.
|
|
12
|
+
*
|
|
13
|
+
* Per aggiungere una nuova lingua:
|
|
14
|
+
* 1. Aggiungi il codice ISO qui (es. `'fr'`).
|
|
15
|
+
* 2. Aggiungi la traduzione corrispondente nell'oggetto `COPY` di ogni
|
|
16
|
+
* file in `templates/`. TypeScript segnalerà le chiavi mancanti.
|
|
17
|
+
*/
|
|
18
|
+
export const SUPPORTED_EMAIL_LOCALES = ['en', 'it'] as const
|
|
19
|
+
export type EmailLocale = (typeof SUPPORTED_EMAIL_LOCALES)[number]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Risolve una stringa locale non verificata (es. dal body di una request)
|
|
23
|
+
* a un valore `EmailLocale` supportato. Qualsiasi valore sconosciuto
|
|
24
|
+
* ricade su `'en'` in modo sicuro.
|
|
25
|
+
*
|
|
26
|
+
* @param raw - Valore grezzo dal client (può essere qualsiasi cosa).
|
|
27
|
+
* @returns Un `EmailLocale` valido, sempre.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveEmailLocale(raw: unknown): EmailLocale {
|
|
30
|
+
if (
|
|
31
|
+
typeof raw === 'string' &&
|
|
32
|
+
(SUPPORTED_EMAIL_LOCALES as readonly string[]).includes(raw)
|
|
33
|
+
) {
|
|
34
|
+
return raw as EmailLocale
|
|
35
|
+
}
|
|
36
|
+
return 'en'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Messaggio outbound ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Il messaggio email risolto che il provider riceve e invia.
|
|
43
|
+
* Viene costruito dal service combinando i parametri della chiamata
|
|
44
|
+
* con l'output del template builder.
|
|
45
|
+
*/
|
|
46
|
+
export interface OutboundEmail {
|
|
47
|
+
/** Indirizzo mittente in formato RFC 5321 (es. "Beech CMS <noreply@beechcms.dev>"). */
|
|
48
|
+
from: string
|
|
49
|
+
/** Lista degli indirizzi destinatari. Deve contenere almeno un elemento. */
|
|
50
|
+
to: string[]
|
|
51
|
+
subject: string
|
|
52
|
+
/** Corpo HTML completo. Deve essere un documento HTML valido (vedi `templates/shell.ts`). */
|
|
53
|
+
html: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Parametri delle funzioni del service ──────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parametri condivisi da ogni funzione di invio email in `email.service.ts`.
|
|
60
|
+
* Le funzioni specifiche estendono questo tipo con i campi aggiuntivi
|
|
61
|
+
* necessari al proprio template.
|
|
62
|
+
*/
|
|
63
|
+
export interface BaseEmailParams {
|
|
64
|
+
/** Indirizzo del destinatario principale. */
|
|
65
|
+
to: string
|
|
66
|
+
/** Lingua del corpo email. Usa `resolveEmailLocale()` prima di passarlo qui. */
|
|
67
|
+
locale: EmailLocale
|
|
68
|
+
/**
|
|
69
|
+
* API key Resend (o del provider attivo). Deve essere non vuota —
|
|
70
|
+
* il chiamante è responsabile di validarla prima di invocare il service.
|
|
71
|
+
*/
|
|
72
|
+
apiKey: string
|
|
73
|
+
/**
|
|
74
|
+
* Indirizzo mittente in formato RFC 5321.
|
|
75
|
+
* Default: "Beech CMS <onboarding@resend.dev>" (mittente di test Resend).
|
|
76
|
+
* In produzione, impostare un indirizzo verificato tramite la variabile
|
|
77
|
+
* d'ambiente `EMAIL_FROM`.
|
|
78
|
+
*/
|
|
79
|
+
from?: string
|
|
80
|
+
/**
|
|
81
|
+
* Quando `true`, gli errori del provider vengono loggati in console.
|
|
82
|
+
* Impostare `false` in produzione per non esporre dettagli interni.
|
|
83
|
+
*/
|
|
84
|
+
isDev?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parametri per l'email di reset password — aggiunge l'URL di reset. */
|
|
88
|
+
export interface PasswordResetEmailParams extends BaseEmailParams {
|
|
89
|
+
/**
|
|
90
|
+
* URL completo che l'utente clicca per impostare la nuova password.
|
|
91
|
+
* Contiene il token in chiaro come query param `?token=<uuid>`.
|
|
92
|
+
* Costruito dal chiamante come `${APP_URL}/reset-password?token=${token}`.
|
|
93
|
+
*/
|
|
94
|
+
resetUrl: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parametri per la notifica "password modificata". Nessun campo aggiuntivo. */
|
|
98
|
+
export type PasswordChangedEmailParams = BaseEmailParams
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API — modulo email di Beech CMS
|
|
3
|
+
*
|
|
4
|
+
* Questo è l'UNICO file da importare da codice esterno a questa feature.
|
|
5
|
+
* I dettagli implementativi interni (provider, template, shell) sono privati
|
|
6
|
+
* alla slice e non devono mai essere importati direttamente dall'esterno.
|
|
7
|
+
*
|
|
8
|
+
* ─── FUNZIONI ESPORTATE ───────────────────────────────────────────────────────
|
|
9
|
+
* sendPasswordResetEmail — invia l'email con il link di reset
|
|
10
|
+
* sendPasswordChangedEmail — invia la notifica "password modificata"
|
|
11
|
+
*
|
|
12
|
+
* ─── TIPI E UTILITY ESPORTATI ────────────────────────────────────────────────
|
|
13
|
+
* EmailLocale — 'en' | 'it' (aggiungere lingue in email.types.ts)
|
|
14
|
+
* resolveEmailLocale — resolver sicuro per locale da input non verificato
|
|
15
|
+
* PasswordResetEmailParams — shape dei parametri per sendPasswordResetEmail
|
|
16
|
+
* PasswordChangedEmailParams — shape dei parametri per sendPasswordChangedEmail
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { sendPasswordResetEmail, sendPasswordChangedEmail } from './email.service'
|
|
20
|
+
export {
|
|
21
|
+
resolveEmailLocale,
|
|
22
|
+
SUPPORTED_EMAIL_LOCALES,
|
|
23
|
+
} from './email.types'
|
|
24
|
+
export type {
|
|
25
|
+
EmailLocale,
|
|
26
|
+
PasswordResetEmailParams,
|
|
27
|
+
PasswordChangedEmailParams,
|
|
28
|
+
} from './email.types'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { EmailProvider } from '../email.provider'
|
|
3
|
+
import type { OutboundEmail } from '../email.types'
|
|
4
|
+
|
|
5
|
+
/** Endpoint REST di Resend per l'invio email. */
|
|
6
|
+
const RESEND_API_URL = 'https://api.resend.com/emails'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Implementazione Resend di EmailProvider.
|
|
10
|
+
*
|
|
11
|
+
* Questo è l'UNICO file del modulo email che conosce Resend.
|
|
12
|
+
* Ogni altro file è completamente ignaro di quale provider sia attivo.
|
|
13
|
+
*
|
|
14
|
+
* ─── COME SOSTITUIRE QUESTO PROVIDER ─────────────────────────────────────────
|
|
15
|
+
* 1. Crea un nuovo file in `providers/` (es. `providers/sendgrid.ts`).
|
|
16
|
+
* 2. Esporta una classe che implementa `EmailProvider` (un solo metodo: `send`).
|
|
17
|
+
* 3. In `email.service.ts` sostituisci `new ResendEmailProvider(…)` con la
|
|
18
|
+
* tua nuova classe nella funzione `createProvider()`.
|
|
19
|
+
* 4. Aggiorna le variabili d'ambiente in `types.ts` e `wrangler.jsonc`.
|
|
20
|
+
* 5. Nessun altro file nel progetto va modificato.
|
|
21
|
+
*
|
|
22
|
+
* Documentazione API Resend: https://resend.com/docs/api-reference/emails/send-email
|
|
23
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
*/
|
|
25
|
+
export class ResendEmailProvider implements EmailProvider {
|
|
26
|
+
private readonly apiKey: string
|
|
27
|
+
|
|
28
|
+
/** Quando `true`, gli errori vengono loggati in console (solo in sviluppo). */
|
|
29
|
+
private readonly isDev: boolean
|
|
30
|
+
|
|
31
|
+
constructor(apiKey: string, isDev = false) {
|
|
32
|
+
this.apiKey = apiKey
|
|
33
|
+
this.isDev = isDev
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Invia l'email tramite la REST API di Resend (`POST /emails`).
|
|
38
|
+
*
|
|
39
|
+
* Lancia un'eccezione se Resend risponde con uno status non-2xx, in modo
|
|
40
|
+
* che il chiamante (`email.service.ts`) possa decidere se propagare l'errore
|
|
41
|
+
* o gestirlo silenziosamente (fire-and-forget).
|
|
42
|
+
*
|
|
43
|
+
* Il corpo della response viene letto per il log solo in ambiente di sviluppo,
|
|
44
|
+
* per evitare di consumare il body stream in produzione inutilmente.
|
|
45
|
+
*/
|
|
46
|
+
async send(email: OutboundEmail): Promise<void> {
|
|
47
|
+
const response = await fetch(RESEND_API_URL, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify(email),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const detail = this.isDev
|
|
58
|
+
? await response.text()
|
|
59
|
+
: `HTTP ${response.status}`
|
|
60
|
+
throw new Error(`[ResendEmailProvider] invio fallito — ${detail}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EmailLocale } from '../email.types'
|
|
2
|
+
import { buildEmailShell } from './shell'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Testi localizzati per la notifica di sicurezza "password modificata".
|
|
6
|
+
*
|
|
7
|
+
* ─── AGGIUNGERE UNA NUOVA LINGUA ─────────────────────────────────────────────
|
|
8
|
+
* Vedi le istruzioni in `templates/password-reset.ts`.
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
*/
|
|
11
|
+
const COPY: Record<EmailLocale, {
|
|
12
|
+
subject: string
|
|
13
|
+
title: string
|
|
14
|
+
body: string
|
|
15
|
+
warning: string
|
|
16
|
+
footer: string
|
|
17
|
+
}> = {
|
|
18
|
+
en: {
|
|
19
|
+
subject: 'Your Beech CMS password has been changed',
|
|
20
|
+
title: 'Your password has been changed',
|
|
21
|
+
body: 'Your Beech CMS account password was successfully changed. If you made this change, no action is needed.',
|
|
22
|
+
warning:
|
|
23
|
+
'If you did not make this change, your account may be compromised. Contact your administrator immediately.',
|
|
24
|
+
footer: 'This is an automated security notification. Do not reply to this email.',
|
|
25
|
+
},
|
|
26
|
+
it: {
|
|
27
|
+
subject: 'La tua password Beech CMS è stata modificata',
|
|
28
|
+
title: 'La tua password è stata modificata',
|
|
29
|
+
body: 'La password del tuo account Beech CMS è stata modificata con successo. Se hai effettuato tu questa modifica, non devi fare nulla.',
|
|
30
|
+
warning:
|
|
31
|
+
'Se non hai effettuato tu questa modifica, il tuo account potrebbe essere compromesso. Contatta immediatamente il tuo amministratore.',
|
|
32
|
+
footer: 'Questa è una notifica di sicurezza automatica. Non rispondere a questa email.',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Costruisce l'email di notifica "password modificata".
|
|
38
|
+
*
|
|
39
|
+
* Questa email non ha un pulsante CTA — è una pura notifica di sicurezza.
|
|
40
|
+
* Il blocco `warning` (testo rosso) avvisa l'utente di agire se non è
|
|
41
|
+
* stato lui a modificare la password.
|
|
42
|
+
*
|
|
43
|
+
* @param locale - Lingua per oggetto e corpo dell'email.
|
|
44
|
+
* @returns Oggetto con `subject` (stringa) e `html` (documento HTML completo).
|
|
45
|
+
*/
|
|
46
|
+
export function buildPasswordChangedEmail(
|
|
47
|
+
locale: EmailLocale,
|
|
48
|
+
): { subject: string; html: string } {
|
|
49
|
+
const c = COPY[locale]
|
|
50
|
+
return {
|
|
51
|
+
subject: c.subject,
|
|
52
|
+
html: buildEmailShell(locale, {
|
|
53
|
+
title: c.title,
|
|
54
|
+
body: c.body,
|
|
55
|
+
warning: c.warning,
|
|
56
|
+
footer: c.footer,
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { EmailLocale } from '../email.types'
|
|
2
|
+
import { buildEmailShell } from './shell'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Testi localizzati per l'email di reset password.
|
|
6
|
+
*
|
|
7
|
+
* ─── AGGIUNGERE UNA NUOVA LINGUA ─────────────────────────────────────────────
|
|
8
|
+
* 1. Aggiungi il codice ISO in `SUPPORTED_EMAIL_LOCALES` (email.types.ts).
|
|
9
|
+
* 2. Aggiungi una chiave corrispondente in questo oggetto con tutti i campi.
|
|
10
|
+
* TypeScript segnala immediatamente le chiavi mancanti grazie a
|
|
11
|
+
* `Record<EmailLocale, …>`.
|
|
12
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
*/
|
|
14
|
+
const COPY: Record<EmailLocale, {
|
|
15
|
+
subject: string
|
|
16
|
+
title: string
|
|
17
|
+
body: string
|
|
18
|
+
ctaLabel: string
|
|
19
|
+
footer: string
|
|
20
|
+
}> = {
|
|
21
|
+
en: {
|
|
22
|
+
subject: 'Reset your Beech CMS password',
|
|
23
|
+
title: 'Reset your password',
|
|
24
|
+
body: 'You requested a password reset for your Beech CMS account. Click the button below to set a new password. This link expires in 30 minutes.',
|
|
25
|
+
ctaLabel: 'Reset password',
|
|
26
|
+
footer: "If you didn't request this, you can safely ignore this email.",
|
|
27
|
+
},
|
|
28
|
+
it: {
|
|
29
|
+
subject: 'Reimposta la tua password Beech CMS',
|
|
30
|
+
title: 'Reimposta la tua password',
|
|
31
|
+
body: 'Hai richiesto il reset della password per il tuo account Beech CMS. Clicca il pulsante qui sotto per impostare una nuova password. Questo link scade tra 30 minuti.',
|
|
32
|
+
ctaLabel: 'Reimposta password',
|
|
33
|
+
footer: 'Se non hai richiesto questo, puoi ignorare questa email in tutta sicurezza.',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Costruisce l'email con il link di reset password.
|
|
39
|
+
*
|
|
40
|
+
* Compone il contenuto localizzato con il layout base (`buildEmailShell`)
|
|
41
|
+
* iniettando il pulsante CTA che punta all'URL di reset.
|
|
42
|
+
*
|
|
43
|
+
* @param resetUrl - URL completo con il token in chiaro, es.
|
|
44
|
+
* `https://dashboard.beechcms.dev/reset-password?token=<uuid>`.
|
|
45
|
+
* Viene incorporato direttamente nel pulsante CTA — non sanificare
|
|
46
|
+
* ulteriormente: il token è un UUID generato internamente.
|
|
47
|
+
* @param locale - Lingua per oggetto e corpo dell'email.
|
|
48
|
+
* @returns Oggetto con `subject` (stringa) e `html` (documento HTML completo).
|
|
49
|
+
*/
|
|
50
|
+
export function buildPasswordResetEmail(
|
|
51
|
+
resetUrl: string,
|
|
52
|
+
locale: EmailLocale,
|
|
53
|
+
): { subject: string; html: string } {
|
|
54
|
+
const c = COPY[locale]
|
|
55
|
+
return {
|
|
56
|
+
subject: c.subject,
|
|
57
|
+
html: buildEmailShell(locale, {
|
|
58
|
+
title: c.title,
|
|
59
|
+
body: c.body,
|
|
60
|
+
cta: { label: c.ctaLabel, href: resetUrl },
|
|
61
|
+
footer: c.footer,
|
|
62
|
+
}),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { EmailLocale } from '../email.types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slot di contenuto che ogni template deve fornire per comporre un'email completa.
|
|
5
|
+
* Ogni slot corrisponde a un blocco visivo nel layout della card email.
|
|
6
|
+
*/
|
|
7
|
+
export interface EmailShellSlots {
|
|
8
|
+
/**
|
|
9
|
+
* Heading H2 mostrato in cima alla card. Mantienilo sotto ~50 caratteri
|
|
10
|
+
* per garantire una buona leggibilità su client mobile.
|
|
11
|
+
*/
|
|
12
|
+
title: string
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Testo principale del corpo. Renderizzato come paragrafo.
|
|
16
|
+
* È ammesso HTML inline sicuro (es. `<strong>`, `<a href="...">`),
|
|
17
|
+
* ma evita elementi block (`<p>`, `<div>`) che potrebbero rompere
|
|
18
|
+
* la struttura del layout in client email rigidi (Outlook, Gmail).
|
|
19
|
+
*/
|
|
20
|
+
body: string
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pulsante call-to-action opzionale, renderizzato come link-button scuro.
|
|
24
|
+
* Ometti per email di sola notifica che non richiedono azione da parte dell'utente.
|
|
25
|
+
*/
|
|
26
|
+
cta?: { label: string; href: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Paragrafo di avviso opzionale. Renderizzato in rosso (#ef4444) per attirare
|
|
30
|
+
* l'attenzione. Usalo per avvisi di sicurezza
|
|
31
|
+
* ("se non sei stato tu, agisci immediatamente").
|
|
32
|
+
*/
|
|
33
|
+
warning?: string
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Testo piccolo grigio in fondo alla card.
|
|
37
|
+
* Usato per note del tipo "notifica automatica, non rispondere".
|
|
38
|
+
*/
|
|
39
|
+
footer: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Costruisce il layout HTML base condiviso da tutte le email transazionali
|
|
44
|
+
* di Beech CMS.
|
|
45
|
+
*
|
|
46
|
+
* ─── FONTE UNICA DI VERITÀ PER IL BRANDING ───────────────────────────────────
|
|
47
|
+
* Modificare questa funzione cambia l'aspetto visivo di TUTTE le email
|
|
48
|
+
* in uscita contemporaneamente:
|
|
49
|
+
* - colore di sfondo e della card
|
|
50
|
+
* - stile del bordo e del border-radius
|
|
51
|
+
* - scala tipografica e spaziatura
|
|
52
|
+
* - stile del pulsante CTA
|
|
53
|
+
*
|
|
54
|
+
* Per cambiare il testo o la struttura di una email specifica, modifica invece
|
|
55
|
+
* il file template corrispondente (`templates/password-reset.ts`, ecc.).
|
|
56
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
*
|
|
58
|
+
* @param locale - Usato per l'attributo `lang` del tag `<html>`.
|
|
59
|
+
* @param slots - Blocchi di contenuto iniettati nel layout.
|
|
60
|
+
* @returns Un documento HTML completo e self-contained pronto per l'invio.
|
|
61
|
+
*/
|
|
62
|
+
export function buildEmailShell(locale: EmailLocale, slots: EmailShellSlots): string {
|
|
63
|
+
const ctaBlock = slots.cta
|
|
64
|
+
? `<a href="${slots.cta.href}"
|
|
65
|
+
style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
|
|
66
|
+
border-radius:6px;text-decoration:none;font-size:15px;font-weight:500;
|
|
67
|
+
margin-bottom:24px">
|
|
68
|
+
${slots.cta.label}
|
|
69
|
+
</a>`
|
|
70
|
+
: ''
|
|
71
|
+
|
|
72
|
+
const warningBlock = slots.warning
|
|
73
|
+
? `<p style="margin:0 0 24px;color:#ef4444;font-size:15px;line-height:1.5;font-weight:500">
|
|
74
|
+
${slots.warning}
|
|
75
|
+
</p>`
|
|
76
|
+
: ''
|
|
77
|
+
|
|
78
|
+
return `<!DOCTYPE html>
|
|
79
|
+
<html lang="${locale}">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="UTF-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
83
|
+
</head>
|
|
84
|
+
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:32px">
|
|
85
|
+
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;
|
|
86
|
+
padding:32px;border:1px solid #e5e5e5">
|
|
87
|
+
<h2 style="margin:0 0 16px;font-size:20px;color:#111">${slots.title}</h2>
|
|
88
|
+
<p style="margin:0 0 24px;color:#555;font-size:15px;line-height:1.5">${slots.body}</p>
|
|
89
|
+
${ctaBlock}${warningBlock}<p style="margin:0;color:#999;font-size:13px">${slots.footer}</p>
|
|
90
|
+
</div>
|
|
91
|
+
</body>
|
|
92
|
+
</html>`
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { notificationsApp } from './notifications.handler'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import type { Env, Variables } from '../../types'
|
|
4
|
+
|
|
5
|
+
const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
6
|
+
|
|
7
|
+
// GET /notifications — lista con ETag/304
|
|
8
|
+
notificationsApp.get('/notifications', async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const { DB } = c.env
|
|
11
|
+
|
|
12
|
+
const stats = await DB.prepare(
|
|
13
|
+
'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
|
|
14
|
+
).first<{ count: number; latest: number | null; read_sum: number | null }>()
|
|
15
|
+
|
|
16
|
+
const count = stats?.count ?? 0
|
|
17
|
+
const latest = stats?.latest ?? 0
|
|
18
|
+
const readSum = stats?.read_sum ?? 0
|
|
19
|
+
const etag = `W/"${count}-${latest}-${readSum}"`
|
|
20
|
+
|
|
21
|
+
const ifNoneMatch = c.req.header('If-None-Match')
|
|
22
|
+
if (ifNoneMatch === etag) {
|
|
23
|
+
return new Response(null, { status: 304 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await DB.prepare(
|
|
27
|
+
'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
|
|
28
|
+
).all()
|
|
29
|
+
|
|
30
|
+
c.header('ETag', etag)
|
|
31
|
+
c.header('Cache-Control', 'no-cache, must-revalidate')
|
|
32
|
+
|
|
33
|
+
return c.json(result.results ?? [])
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('Notifications fetch error:', err)
|
|
36
|
+
return c.json({ error: 'Failed to fetch notifications' }, 500)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
// PATCH /notifications/:id/read — segna come letta
|
|
42
|
+
notificationsApp.patch('/notifications/:id/read', async (c) => {
|
|
43
|
+
try {
|
|
44
|
+
const id = c.req.param('id')
|
|
45
|
+
const { DB } = c.env
|
|
46
|
+
await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').bind(id).run()
|
|
47
|
+
return c.json({ success: true })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return c.json({ error: 'Failed to update notification' }, 500)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
// PATCH /notifications/:id/unread - Segna come non letta
|
|
53
|
+
notificationsApp.patch('/notifications/:id/unread', async (c) => {
|
|
54
|
+
try {
|
|
55
|
+
const id = c.req.param('id')
|
|
56
|
+
const { DB } = c.env
|
|
57
|
+
await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?').bind(id).run()
|
|
58
|
+
return c.json({ success: true })
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return c.json({ error: 'Failed to update notification' }, 500)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// DELETE /notifications/:id — elimina notifica
|
|
66
|
+
notificationsApp.delete('/notifications/:id', async (c) => {
|
|
67
|
+
try {
|
|
68
|
+
const id = c.req.param('id')
|
|
69
|
+
const { DB } = c.env
|
|
70
|
+
await DB.prepare('DELETE FROM notifications WHERE id = ?').bind(id).run()
|
|
71
|
+
return c.json({ success: true })
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return c.json({ error: 'Failed to delete notification' }, 500)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
// POST /notifications/mark-all-read — segna tutte come lette
|
|
79
|
+
notificationsApp.post('/notifications/mark-all-read', async (c) => {
|
|
80
|
+
try {
|
|
81
|
+
const { DB } = c.env
|
|
82
|
+
await DB.prepare('UPDATE notifications SET is_read = 1').run()
|
|
83
|
+
return c.json({ success: true })
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return c.json({ error: 'Failed to update notifications' }, 500)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
export { notificationsApp }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import type { Env, Variables } from '../../types'
|
|
4
|
+
import { requestPasswordReset } from './request'
|
|
5
|
+
import { resetPassword } from './reset'
|
|
6
|
+
|
|
7
|
+
export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
8
|
+
|
|
9
|
+
// Feature flag: lets the dashboard know whether to show the forgot-password link
|
|
10
|
+
passwordResetApp.get('/auth/features', (c) => {
|
|
11
|
+
return c.json({ passwordReset: Boolean(c.env.RESEND_API_KEY) })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
|
|
15
|
+
passwordResetApp.post('/auth/reset-password', resetPassword)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import type { Env, Variables } from '../../types'
|
|
4
|
+
import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
|
|
5
|
+
|
|
6
|
+
const TOKEN_EXPIRY_SECONDS = 30 * 60
|
|
7
|
+
|
|
8
|
+
async function sha256hex(text: string): Promise<string> {
|
|
9
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
|
10
|
+
return Array.from(new Uint8Array(buf))
|
|
11
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
12
|
+
.join('')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function requestPasswordReset(
|
|
16
|
+
c: Context<{ Bindings: Env; Variables: Variables }>
|
|
17
|
+
): Promise<Response> {
|
|
18
|
+
if (!c.env.RESEND_API_KEY) {
|
|
19
|
+
return c.json({ error: 'Not available' }, 503)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let body: Record<string, unknown>
|
|
23
|
+
try {
|
|
24
|
+
body = await c.req.json()
|
|
25
|
+
} catch {
|
|
26
|
+
return c.json({ error: 'Invalid request body' }, 400)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof body.email !== 'string' || !body.email.trim()) {
|
|
30
|
+
return c.json({ error: 'Invalid request' }, 400)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const email = body.email.trim().toLowerCase()
|
|
34
|
+
const locale = resolveEmailLocale(body.locale)
|
|
35
|
+
|
|
36
|
+
if (c.env.FORGOT_PASSWORD_RATE_LIMITER) {
|
|
37
|
+
const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
|
|
38
|
+
const { success } = await c.env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: ip })
|
|
39
|
+
if (!success) {
|
|
40
|
+
return c.json({ error: 'Too many requests' }, 429)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Sempre 200 per evitare user enumeration
|
|
45
|
+
const user = await c.env.DB
|
|
46
|
+
.prepare('SELECT id FROM users WHERE email = ?')
|
|
47
|
+
.bind(email)
|
|
48
|
+
.first<{ id: string }>()
|
|
49
|
+
|
|
50
|
+
if (!user) {
|
|
51
|
+
return c.json({ success: true })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Invalida eventuali token pendenti per lo stesso utente prima di emetterne uno nuovo
|
|
55
|
+
await c.env.DB
|
|
56
|
+
.prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
|
|
57
|
+
.bind(user.id)
|
|
58
|
+
.run()
|
|
59
|
+
|
|
60
|
+
const token = crypto.randomUUID()
|
|
61
|
+
const tokenHash = await sha256hex(token)
|
|
62
|
+
const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS
|
|
63
|
+
|
|
64
|
+
await c.env.DB
|
|
65
|
+
.prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
|
|
66
|
+
.bind(crypto.randomUUID(), user.id, tokenHash, expiresAt)
|
|
67
|
+
.run()
|
|
68
|
+
|
|
69
|
+
const appUrl = (c.env.APP_URL ?? new URL(c.req.url).origin).replace(/\/$/, '')
|
|
70
|
+
const resetUrl = `${appUrl}/reset-password?token=${token}`
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await sendPasswordResetEmail({
|
|
74
|
+
to: email,
|
|
75
|
+
resetUrl,
|
|
76
|
+
locale,
|
|
77
|
+
apiKey: c.env.RESEND_API_KEY,
|
|
78
|
+
from: c.env.EMAIL_FROM,
|
|
79
|
+
isDev: c.env.ENV !== 'production',
|
|
80
|
+
})
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (c.env.ENV !== 'production') {
|
|
83
|
+
console.error('[password-reset] invio email fallito:', err)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return c.json({ success: true })
|
|
88
|
+
}
|