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="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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const services: Service[] = [
|
||||
},
|
||||
{
|
||||
slug: "performance-class",
|
||||
title: "Performance Class",
|
||||
title: "Class",
|
||||
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.",
|
||||
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",
|
||||
cityLine: "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: [
|
||||
{ label: "Instagram", url: "https://www.instagram.com/in_sanity_lab" },
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/company/in-sanity-lab-srl/" },
|
||||
@@ -31,7 +31,7 @@ export const site = {
|
||||
],
|
||||
footerServices: [
|
||||
{ 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: "Profilazione", href: "/services/profilazione" },
|
||||
{ label: "Osteopatia", href: "/services/osteopatia" },
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
export interface ContactData {
|
||||
firstName: string; lastName: string; phone: string;
|
||||
email: string; message: string; website: string;
|
||||
email: string; message: string; hpField: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -20,9 +20,10 @@ export function validateContact(data: unknown):
|
||||
phone: str(d.phone, 40),
|
||||
email: str(d.email, 200),
|
||||
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.lastName) return { ok: false, error: 'Il cognome è 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); });
|
||||
updPromoTx();
|
||||
|
||||
// Fix: il titolo del servizio Performance Class era rimasto troncato a "Class" nei DB esistenti
|
||||
// (valore vecchio mai allineato dal seed INSERT OR IGNORE), mostrando "CLASS" come H1 della
|
||||
// pagina /services/performance-class e nelle card di /services e home. Guardato dal vecchio
|
||||
// valore → idempotente, non tocca eventuali modifiche del pannello.
|
||||
db.prepare("UPDATE content_blocks SET value = 'Performance Class' WHERE tag = 'service.performance-class.title' AND value = 'Class'").run();
|
||||
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
|
||||
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
|
||||
// Class. Guardato dal vecchio 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 = '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
|
||||
// (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.
|
||||
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
|
||||
// distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e
|
||||
// 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 {
|
||||
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 { validateContact } from '../../lib/contact';
|
||||
import { sendContactEmail } from '../../lib/mailer';
|
||||
import { rateLimit } from '../../lib/rate-limit';
|
||||
import { rateLimit, clientIp } from '../../lib/rate-limit';
|
||||
|
||||
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' } });
|
||||
|
||||
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;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const result = validateContact(data);
|
||||
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 {
|
||||
await sendContactEmail(result.value);
|
||||
} catch (err) {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('validateContact', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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 path = join(dir, 'test.db');
|
||||
try {
|
||||
// DB con il vecchio valore troncato.
|
||||
// DB con il vecchio valore normalizzato "Performance Class".
|
||||
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();
|
||||
|
||||
const db2 = createDb(path);
|
||||
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();
|
||||
|
||||
// Non deve toccare un valore modificato dal pannello.
|
||||
|
||||
Reference in New Issue
Block a user