Fix contatti + rinomina servizi + orario sabato
- Contatti: rate-limit per IP reale (X-Forwarded-For) invece dell'IP del proxy Traefik, condiviso da tutti; controllo spostato dopo la validazione così bot/honeypot non consumano la quota. Honeypot rinominato da "website" a "hp_field" per non farlo riempire dall'autofill del browser (falsi 400). - Servizi: "Performance Class" -> "Class" (titolo/card/H1/footer); migrazione DB invertita e guardata. - One to One: migrazione titolo prod "Personal training" -> "One to One". - Footer: orario sabato 8-17 (non 8-19), seed + migrazione guardata. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ const { variant = 'full' } = Astro.props;
|
|||||||
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
|
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
|
||||||
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
|
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
|
||||||
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
|
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
|
||||||
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
|
<input class="hp" type="text" name="hp_field" tabindex="-1" autocomplete="off" aria-hidden="true" />
|
||||||
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
|
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
|
||||||
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
|
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
|
||||||
<p class="form-msg" hidden></p>
|
<p class="form-msg" hidden></p>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const services: Service[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "performance-class",
|
slug: "performance-class",
|
||||||
title: "Performance Class",
|
title: "Class",
|
||||||
subtitle: "Gruppo max 8 persone",
|
subtitle: "Gruppo max 8 persone",
|
||||||
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
excerpt: "La Performance Class è un allenamento in piccoli gruppi, basato su una programmazione progressiva che combina forza e condizionamento per ottenere risultati concreti e costanti nel tempo.",
|
||||||
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
longDescription: "La Performance Class è un allenamento in piccoli gruppi con programmazione progressiva e scientifica, pensato per migliorare forza, composizione corporea e performance attraverso sessioni strutturate che combinano forza, ipertrofia e condizionamento metabolico. Non si improvvisa: ogni sessione ha un posto preciso all’interno di un piano più ampio.",
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ export const site = {
|
|||||||
address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
|
address: "Via Leandro Alberti 76 e Via De Mattiolo 5",
|
||||||
cityLine: "40139 Bologna",
|
cityLine: "40139 Bologna",
|
||||||
mapQuery: "IN-SANITY LAB, Via Leandro Alberti 76, 40139 Bologna",
|
mapQuery: "IN-SANITY LAB, Via Leandro Alberti 76, 40139 Bologna",
|
||||||
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 19.00 · Dom chiuso"],
|
hours: ["Lun – Ven 6.00 – 21.00", "Sab 8.00 – 17.00 · Dom chiuso"],
|
||||||
socials: [
|
socials: [
|
||||||
{ label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
|
{ label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
|
||||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
|
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
|
||||||
@@ -31,7 +31,7 @@ export const site = {
|
|||||||
],
|
],
|
||||||
footerServices: [
|
footerServices: [
|
||||||
{ label: "Personal Training", href: "/services/personal-training" },
|
{ label: "Personal Training", href: "/services/personal-training" },
|
||||||
{ label: "Performance Class", href: "/services/performance-class" },
|
{ label: "Class", href: "/services/performance-class" },
|
||||||
{ label: "Coaching", href: "/services/coaching" },
|
{ label: "Coaching", href: "/services/coaching" },
|
||||||
{ label: "Profilazione", href: "/services/profilazione" },
|
{ label: "Profilazione", href: "/services/profilazione" },
|
||||||
{ label: "Osteopatia", href: "/services/osteopatia" },
|
{ label: "Osteopatia", href: "/services/osteopatia" },
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
export interface ContactData {
|
export interface ContactData {
|
||||||
firstName: string; lastName: string; phone: string;
|
firstName: string; lastName: string; phone: string;
|
||||||
email: string; message: string; website: string;
|
email: string; message: string; hpField: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@@ -20,9 +20,10 @@ export function validateContact(data: unknown):
|
|||||||
phone: str(d.phone, 40),
|
phone: str(d.phone, 40),
|
||||||
email: str(d.email, 200),
|
email: str(d.email, 200),
|
||||||
message: str(d.message, 5000),
|
message: str(d.message, 5000),
|
||||||
website: str(d.website, 200),
|
// Honeypot: nome neutro per non farlo riempire dall'autofill del browser.
|
||||||
|
hpField: str(d.hp_field, 200),
|
||||||
};
|
};
|
||||||
if (value.website) return { ok: false, error: 'Richiesta non valida.' };
|
if (value.hpField) return { ok: false, error: 'Richiesta non valida.' };
|
||||||
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
|
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
|
||||||
if (!value.lastName) return { ok: false, error: 'Il cognome è obbligatorio.' };
|
if (!value.lastName) return { ok: false, error: 'Il cognome è obbligatorio.' };
|
||||||
if (!value.phone) return { ok: false, error: 'Il telefono è obbligatorio.' };
|
if (!value.phone) return { ok: false, error: 'Il telefono è obbligatorio.' };
|
||||||
|
|||||||
+16
-5
@@ -177,11 +177,18 @@ export function createDb(path?: string): Database.Database {
|
|||||||
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
|
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
|
||||||
updPromoTx();
|
updPromoTx();
|
||||||
|
|
||||||
// Fix: il titolo del servizio Performance Class era rimasto troncato a "Class" nei DB esistenti
|
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
|
||||||
// (valore vecchio mai allineato dal seed INSERT OR IGNORE), mostrando "CLASS" come H1 della
|
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
|
||||||
// pagina /services/performance-class e nelle card di /services e home. Guardato dal vecchio
|
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
|
||||||
// valore → idempotente, non tocca eventuali modifiche del pannello.
|
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'service.performance-class.title' AND value = 'Performance Class'").run();
|
||||||
db.prepare("UPDATE content_blocks SET value = 'Performance Class' WHERE tag = 'service.performance-class.title' AND value = 'Class'").run();
|
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'footer.services.2.label' AND value = 'Performance Class'").run();
|
||||||
|
|
||||||
|
// Titolo del servizio One to One: in prod era stato editato da pannello a "Personal training"
|
||||||
|
// (t minuscola → la vecchia migrazione, guardata su 'Personal Training', non l'agganciava),
|
||||||
|
// così l'hero e l'H2 di /services/one-to-one mostravano "PERSONAL TRAINING". Riportiamo il
|
||||||
|
// nome canonico "One to One" (già nel seed). Guardato sul valore reale corrente → idempotente;
|
||||||
|
// la descrizione lunga editata da pannello NON viene toccata.
|
||||||
|
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal training'").run();
|
||||||
|
|
||||||
// Pulizia tag orfani: il riquadro training "performance-class" è stato rimosso dal seed
|
// Pulizia tag orfani: il riquadro training "performance-class" è stato rimosso dal seed
|
||||||
// (commit 9966ec3, "Training: 6 riquadri"), ma i suoi tag restano nei DB esistenti — orfani,
|
// (commit 9966ec3, "Training: 6 riquadri"), ma i suoi tag restano nei DB esistenti — orfani,
|
||||||
@@ -194,6 +201,10 @@ export function createDb(path?: string): Database.Database {
|
|||||||
// già esistente, se non modificato dal pannello. Idempotente.
|
// già esistente, se non modificato dal pannello. Idempotente.
|
||||||
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
|
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
|
||||||
|
|
||||||
|
// Orario sabato corretto: 8–17, non 8–19. Guardato dal vecchio valore di default →
|
||||||
|
// idempotente, non tocca eventuali modifiche del pannello.
|
||||||
|
db.prepare("UPDATE content_blocks SET value = 'Sab 8.00 – 17.00 · Dom chiuso' WHERE tag = 'global.hours.2' AND value = 'Sab 8.00 – 19.00 · Dom chiuso'").run();
|
||||||
|
|
||||||
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
|
// Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE
|
||||||
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
|
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
|
||||||
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
|
// rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta).
|
||||||
|
|||||||
@@ -15,3 +15,10 @@ export function rateLimit(key: string, max: number, windowMs: number): boolean {
|
|||||||
export function _resetBuckets(): void {
|
export function _resetBuckets(): void {
|
||||||
buckets.clear();
|
buckets.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dietro Traefik `clientAddress` è l'IP interno del proxy, uguale per tutti:
|
||||||
|
// usiamo il primo IP di X-Forwarded-For (client reale) come chiave rate-limit.
|
||||||
|
export function clientIp(request: Request, fallback: string): string {
|
||||||
|
const first = request.headers.get('x-forwarded-for')?.split(',')[0].trim();
|
||||||
|
return first || fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { validateContact } from '../../lib/contact';
|
import { validateContact } from '../../lib/contact';
|
||||||
import { sendContactEmail } from '../../lib/mailer';
|
import { sendContactEmail } from '../../lib/mailer';
|
||||||
import { rateLimit } from '../../lib/rate-limit';
|
import { rateLimit, clientIp } from '../../lib/rate-limit';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -9,13 +9,15 @@ const json = (status: number, body: object) =>
|
|||||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||||
if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
|
|
||||||
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
|
|
||||||
}
|
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||||
const result = validateContact(data);
|
const result = validateContact(data);
|
||||||
if (!result.ok) return json(400, { error: result.error });
|
if (!result.ok) return json(400, { error: result.error });
|
||||||
|
// Rate-limit solo sugli invii validi, per IP reale: bot/honeypot non
|
||||||
|
// consumano la quota di un utente legittimo.
|
||||||
|
if (!rateLimit(`contact:${clientIp(request, clientAddress)}`, 5, 60 * 60 * 1000)) {
|
||||||
|
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await sendContactEmail(result.value);
|
await sendContactEmail(result.value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('validateContact', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rifiuta honeypot compilato', () => {
|
it('rifiuta honeypot compilato', () => {
|
||||||
expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false);
|
expect(validateContact({ ...good, hp_field: 'spam.com' }).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rifiuta messaggi oltre 5000 caratteri', () => {
|
it('rifiuta messaggi oltre 5000 caratteri', () => {
|
||||||
|
|||||||
@@ -113,18 +113,18 @@ describe('schema content_blocks e ruoli', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('corregge il titolo Performance Class rimasto troncato a "Class"', () => {
|
it('riporta il titolo del servizio a "Class" (inverte Performance Class)', () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-pc-'));
|
const dir = mkdtempSync(join(tmpdir(), 'il-db-pc-'));
|
||||||
const path = join(dir, 'test.db');
|
const path = join(dir, 'test.db');
|
||||||
try {
|
try {
|
||||||
// DB con il vecchio valore troncato.
|
// DB con il vecchio valore normalizzato "Performance Class".
|
||||||
const db1 = createDb(path);
|
const db1 = createDb(path);
|
||||||
db1.prepare("UPDATE content_blocks SET value = 'Class', updated_by = NULL WHERE tag = 'service.performance-class.title'").run();
|
db1.prepare("UPDATE content_blocks SET value = 'Performance Class', updated_by = NULL WHERE tag = 'service.performance-class.title'").run();
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
const db2 = createDb(path);
|
const db2 = createDb(path);
|
||||||
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
||||||
expect(val('service.performance-class.title')).toBe('Performance Class');
|
expect(val('service.performance-class.title')).toBe('Class');
|
||||||
db2.close();
|
db2.close();
|
||||||
|
|
||||||
// Non deve toccare un valore modificato dal pannello.
|
// Non deve toccare un valore modificato dal pannello.
|
||||||
|
|||||||
Reference in New Issue
Block a user