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:
2026-07-21 17:58:26 +02:00
parent 22c9cadb43
commit b17b5c831a
9 changed files with 42 additions and 21 deletions
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 allinterno 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 allinterno di un piano più ampio.",
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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: 817, non 819. 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).
+7
View File
@@ -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;
}
+6 -4
View File
@@ -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) {
+1 -1
View File
@@ -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', () => {
+4 -4
View File
@@ -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.