From 3587ba7f26fc111dd7f233393a9daafdeca8a0f8 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sat, 2 May 2026 11:24:11 +1200 Subject: [PATCH] Add honeypot, spam protection to contact form --- mail-api/__pycache__/main.cpython-314.pyc | Bin 0 -> 44868 bytes mail-api/logs/mail-api.log | 2 + mail-api/main.py | 158 ++++++++++++++- src/lib/components/BookingSection.svelte | 32 +++ src/lib/components/BookingSection.test.ts | 2 + src/lib/components/Footer.svelte | 2 +- src/lib/components/ServicesSection.svelte | 49 ++++- src/lib/content/about.ts | 2 +- src/lib/content/dog-walking.ts | 2 +- src/lib/content/homepage.ts | 14 +- src/lib/content/our-pricing.ts | 2 +- src/lib/content/pack-walks.ts | 2 +- src/lib/content/puppy-visits.ts | 2 +- src/lib/content/static-pages.ts | 6 +- src/lib/styles/layout.css | 57 +++++- src/lib/styles/sections.css | 17 ++ src/routes/+error.svelte | 2 +- src/routes/[slug]/+page.server.ts | 4 + src/routes/[slug]/+page.svelte | 2 +- src/routes/[slug]/slug-page.server.test.ts | 7 + src/routes/[slug]/slug-page.test.ts | 2 +- src/routes/layout.test.ts | 2 +- src/routes/sitemap.xml/+server.ts | 2 +- src/routes/sitemap.xml/sitemap.test.ts | 1 + vite.config.ts | 223 +++++++++++++++++++-- 25 files changed, 553 insertions(+), 41 deletions(-) create mode 100644 mail-api/__pycache__/main.cpython-314.pyc diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5dc8a7de4b679d0f00da15ee146b3535569bf67f GIT binary patch literal 44868 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 uC|3UC`R5D6D5(bAQYu!?8rg8y7CZ|cFF0QtMj dict: "from_email": os.environ.get("FROM_EMAIL", "GoodWalk "), "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), "max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))), + "form_min_seconds": max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))), + "form_max_seconds": max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))), + "rate_limit_window_seconds": max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))), + "rate_limit_max_per_ip": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))), + "rate_limit_max_per_email": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))), + "rate_limit_min_interval_seconds": max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))), } @@ -98,12 +105,27 @@ OWNER_EMAIL = _config["owner_email"] FROM_EMAIL = _config["from_email"] REPLY_TO = _config["reply_to"] MAX_SEND_ATTEMPTS = _config["max_attempts"] +FORM_MIN_SECONDS = _config["form_min_seconds"] +FORM_MAX_SECONDS = _config["form_max_seconds"] +RATE_LIMIT_WINDOW_SECONDS = _config["rate_limit_window_seconds"] +RATE_LIMIT_MAX_PER_IP = _config["rate_limit_max_per_ip"] +RATE_LIMIT_MAX_PER_EMAIL = _config["rate_limit_max_per_email"] +RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"] LOGO_URL = "https://www.goodwalk.co.nz/static/images/goodwalk-auckland-dog-walking-logo.png" logger.info( - "Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d", - FROM_EMAIL, REPLY_TO, OWNER_EMAIL, MAX_SEND_ATTEMPTS, + "Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss", + FROM_EMAIL, + REPLY_TO, + OWNER_EMAIL, + MAX_SEND_ATTEMPTS, + FORM_MIN_SECONDS, + FORM_MAX_SECONDS, + RATE_LIMIT_WINDOW_SECONDS, + RATE_LIMIT_MAX_PER_IP, + RATE_LIMIT_MAX_PER_EMAIL, + RATE_LIMIT_MIN_INTERVAL_SECONDS, ) app = FastAPI(title="GoodWalk Mail API") @@ -147,6 +169,8 @@ class BookingSubmission(BaseModel): location: str message: str = "" services: list[str] = [] + website: str = "" + formStartedAt: int | None = None referrer: str = "" page: str = "" @@ -160,6 +184,119 @@ def _get_ip(request: Request) -> str: return request.client.host if request.client else "unknown" +_submit_attempts_by_ip: dict[str, deque[float]] = {} +_submit_attempts_by_email: dict[str, deque[float]] = {} +_submit_rate_limit_lock = asyncio.Lock() + + +def _trimmed(value: str) -> str: + return value.strip() + + +def _prune_attempts(attempts: deque[float], now: float, window_seconds: int) -> None: + while attempts and now - attempts[0] > window_seconds: + attempts.popleft() + + +def _seconds_until_allowed(last_attempt_at: float, now: float, min_interval_seconds: int) -> int: + retry_after = max(1, int(min_interval_seconds - (now - last_attempt_at))) + return retry_after + + +async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> None: + now = time.monotonic() + normalized_email = email.strip().lower() + + async with _submit_rate_limit_lock: + ip_attempts = _submit_attempts_by_ip.setdefault(ip, deque()) + email_attempts = _submit_attempts_by_email.setdefault(normalized_email, deque()) + + _prune_attempts(ip_attempts, now, RATE_LIMIT_WINDOW_SECONDS) + _prune_attempts(email_attempts, now, RATE_LIMIT_WINDOW_SECONDS) + + if ip_attempts and now - ip_attempts[-1] < RATE_LIMIT_MIN_INTERVAL_SECONDS: + retry_after = _seconds_until_allowed(ip_attempts[-1], now, RATE_LIMIT_MIN_INTERVAL_SECONDS) + logger.warning( + "[%s] rate limited: ip=%s submitted again after %.1fs (minimum %ss)", + request_id, + ip, + now - ip_attempts[-1], + RATE_LIMIT_MIN_INTERVAL_SECONDS, + ) + raise HTTPException( + status_code=429, + detail=f"Please wait about {retry_after} seconds before trying again.", + ) + + if len(ip_attempts) >= RATE_LIMIT_MAX_PER_IP: + logger.warning( + "[%s] rate limited: ip=%s exceeded %d submissions in %ss", + request_id, + ip, + RATE_LIMIT_MAX_PER_IP, + RATE_LIMIT_WINDOW_SECONDS, + ) + raise HTTPException( + status_code=429, + detail="Too many enquiries from this connection. Please try again a little later.", + ) + + if len(email_attempts) >= RATE_LIMIT_MAX_PER_EMAIL: + logger.warning( + "[%s] rate limited: email=%s exceeded %d submissions in %ss", + request_id, + normalized_email, + RATE_LIMIT_MAX_PER_EMAIL, + RATE_LIMIT_WINDOW_SECONDS, + ) + raise HTTPException( + status_code=429, + detail="That email address has reached the enquiry limit for now. Please try again later.", + ) + + ip_attempts.append(now) + email_attempts.append(now) + + +def _enforce_form_timing(request_id: str, data: BookingSubmission) -> None: + if data.formStartedAt is None or data.formStartedAt <= 0: + logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id) + raise HTTPException( + status_code=400, + detail="Please refresh the page and try again.", + ) + + elapsed_seconds = (time.time() * 1000 - data.formStartedAt) / 1000 + + if elapsed_seconds < FORM_MIN_SECONDS: + logger.warning( + "[%s] rejected: form submitted too quickly (%.2fs < %ss)", + request_id, + elapsed_seconds, + FORM_MIN_SECONDS, + ) + raise HTTPException( + status_code=400, + detail="Please take a moment to fill in the form before sending it.", + ) + + if elapsed_seconds > FORM_MAX_SECONDS: + logger.warning( + "[%s] rejected: stale form submission (%.0fs > %ss)", + request_id, + elapsed_seconds, + FORM_MAX_SECONDS, + ) + raise HTTPException( + status_code=400, + detail="This form has been open for too long. Please refresh the page and try again.", + ) + + +def _is_honeypot_triggered(data: BookingSubmission) -> bool: + return bool(_trimmed(data.website)) + + def _parse_ua(ua: str) -> str: if not ua: return "Unknown" @@ -596,6 +733,23 @@ async def submit_booking(data: BookingSubmission, request: Request): ) logger.debug("[%s] full payload: %s", request_id, data.model_dump()) + await _enforce_submit_rate_limits(request_id, ip, str(data.email)) + _enforce_form_timing(request_id, data) + + if _is_honeypot_triggered(data): + logger.warning( + "[%s] honeypot triggered for ip=%s email=%s page=%r", + request_id, + ip, + data.email, + data.page, + ) + return { + "ok": True, + "request_id": request_id, + "ignored": True, + } + failures: list[dict] = [] try: diff --git a/src/lib/components/BookingSection.svelte b/src/lib/components/BookingSection.svelte index 62d7eaf..578b645 100644 --- a/src/lib/components/BookingSection.svelte +++ b/src/lib/components/BookingSection.svelte @@ -1,4 +1,5 @@ -
+

What we do

@@ -27,3 +28,49 @@
+ + diff --git a/src/lib/content/about.ts b/src/lib/content/about.ts index 8059cea..36ddc34 100644 --- a/src/lib/content/about.ts +++ b/src/lib/content/about.ts @@ -40,7 +40,7 @@ export const aboutPageContent: AboutPageContent = { phone: '(022) 642 1011', cta: { label: 'Contact us', - href: '/booking', + href: '/contact-us', variant: 'yellow' } } diff --git a/src/lib/content/dog-walking.ts b/src/lib/content/dog-walking.ts index 35738d4..c9e5c3c 100644 --- a/src/lib/content/dog-walking.ts +++ b/src/lib/content/dog-walking.ts @@ -75,7 +75,7 @@ export const dogWalkingContent: ServicePageContent = { booking: { title: "Let's meet!", subtitle: 'Fill out your details below, and we can arrange a Meet & Greet for a one on one walk!', - formAction: '/booking', + formAction: '/contact-us', serviceOptions: [], ownerStepLabel: 'Your details', dogStepLabel: 'Your dog', diff --git a/src/lib/content/homepage.ts b/src/lib/content/homepage.ts index 4444ee0..d43844e 100644 --- a/src/lib/content/homepage.ts +++ b/src/lib/content/homepage.ts @@ -19,12 +19,12 @@ export const homepageContent: HomePageContent = { { label: 'Puppy Visits', href: '/puppy-visits' }, { label: 'Our Pricing', href: '/our-pricing' }, { label: 'About Us', href: '/about' }, - { label: 'Contact Us', href: '/booking' } + { label: 'Contact Us', href: '/contact-us' } ], - cta: { label: 'Contact Us', href: '/booking', variant: 'yellow' }, + cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' }, instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true }, megaMenuServices: [ - { icon: 'fas fa-paw', label: 'Pack Walks', description: 'Group outdoor adventures', href: '/pack-walks' }, + { icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' }, { icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' }, { icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' } ] @@ -60,7 +60,7 @@ export const homepageContent: HomePageContent = { { icon: 'fas fa-dog', title: 'Pack Walks', - body: 'Small group walks of 4-8 dogs - calm, social, and full of fun for your pup.', + body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.', href: '/pack-walks' }, { @@ -150,7 +150,7 @@ export const homepageContent: HomePageContent = { title: "Let's meet!", subtitle: 'Ready to get started? Book your free, no-obligation Meet & Greet today — just enter your details below', - formAction: '/booking', + formAction: '/contact-us', serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'] }, info: { @@ -205,10 +205,10 @@ export const homepageContent: HomePageContent = { { label: '1:1 Walks', href: '/dog-walking' }, { label: 'Puppy Visits', href: '/puppy-visits' }, { label: 'Our Pricing', href: '/our-pricing' }, - { label: 'Contact Us', href: '/booking' } + { label: 'Contact Us', href: '/contact-us' } ], contactLinks: [ - { label: 'Book a walk', href: '/booking' }, + { label: 'Book a walk', href: '/contact-us' }, { label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true }, { label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true } ], diff --git a/src/lib/content/our-pricing.ts b/src/lib/content/our-pricing.ts index 40e7869..4f48cf7 100644 --- a/src/lib/content/our-pricing.ts +++ b/src/lib/content/our-pricing.ts @@ -48,7 +48,7 @@ export const ourPricingContent: PricingPageContent = { booking: { title: 'Ready to join the Tiny Gang?', subtitle: '', - formAction: '/booking', + formAction: '/contact-us', serviceOptions: [], ownerStepLabel: 'Your details', dogStepLabel: 'Dog details', diff --git a/src/lib/content/pack-walks.ts b/src/lib/content/pack-walks.ts index 064166c..e2474cb 100644 --- a/src/lib/content/pack-walks.ts +++ b/src/lib/content/pack-walks.ts @@ -88,7 +88,7 @@ export const packWalksContent: ServicePageContent = { booking: { title: 'Join the Tiny Gang!', subtitle: '', - formAction: '/booking', + formAction: '/contact-us', serviceOptions: [], ownerStepLabel: 'Your details', dogStepLabel: 'Dog details', diff --git a/src/lib/content/puppy-visits.ts b/src/lib/content/puppy-visits.ts index f04fe61..627bfa2 100644 --- a/src/lib/content/puppy-visits.ts +++ b/src/lib/content/puppy-visits.ts @@ -58,7 +58,7 @@ export const puppyVisitsContent: ServicePageContent = { booking: { title: 'Ready to join the Tiny Gang?', subtitle: '', - formAction: '/booking', + formAction: '/contact-us', serviceOptions: [], ownerStepLabel: 'Your details', dogStepLabel: 'Dog details', diff --git a/src/lib/content/static-pages.ts b/src/lib/content/static-pages.ts index c313ac0..c60491d 100644 --- a/src/lib/content/static-pages.ts +++ b/src/lib/content/static-pages.ts @@ -35,10 +35,10 @@ export const staticPages = { 'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.', canonicalPath: '/about' }, - booking: { - title: 'Booking', + 'contact-us': { + title: 'Contact Us', description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.', - canonicalPath: '/booking' + canonicalPath: '/contact-us' }, 'terms-and-conditions': { title: 'Terms & Conditions', diff --git a/src/lib/styles/layout.css b/src/lib/styles/layout.css index 7870704..646264b 100644 --- a/src/lib/styles/layout.css +++ b/src/lib/styles/layout.css @@ -112,7 +112,11 @@ nav { flex: 1; text-decoration: none; color: var(--text); - transition: background 0.15s; + transform: translateY(0); + transition: + background 0.15s, + transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.18s ease; } .mega-service:hover { @@ -129,13 +133,62 @@ nav { justify-content: center; font-size: 24px; color: #fff; - transition: background 0.15s; + box-shadow: 0 10px 22px rgba(33, 48, 33, 0.12); + transform: translateY(0) rotate(0deg) scale(1); + transition: + background 0.15s, + transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.2s ease; } .mega-service:hover .mega-icon { background: #2d4230; } +@media (hover: hover) and (min-width: 769px) { + .has-mega:hover .mega-service { + animation: mega-service-settle 0.28s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .has-mega:hover .mega-service:nth-child(1) { + animation-delay: 0.02s; + } + + .has-mega:hover .mega-service:nth-child(2) { + animation-delay: 0.06s; + } + + .has-mega:hover .mega-service:nth-child(3) { + animation-delay: 0.1s; + } + + .mega-service:hover { + transform: translateY(-3px); + box-shadow: 0 12px 26px rgba(17, 20, 24, 0.08); + } + + .mega-service:hover .mega-icon { + transform: translateY(-3px) rotate(-5deg) scale(1.04); + box-shadow: 0 16px 28px rgba(33, 48, 33, 0.18); + } + + .mega-service:nth-child(even):hover .mega-icon { + transform: translateY(-3px) rotate(5deg) scale(1.04); + } +} + +@keyframes mega-service-settle { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .mega-service-label { font-weight: 700; font-size: 14px; diff --git a/src/lib/styles/sections.css b/src/lib/styles/sections.css index 24d5a13..dbc56fe 100644 --- a/src/lib/styles/sections.css +++ b/src/lib/styles/sections.css @@ -196,6 +196,10 @@ section { .service-icon-bubble { background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%); + transform: translateY(0) rotate(0deg) scale(1); + transition: + transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.2s ease; } @media (hover: hover) { @@ -205,6 +209,19 @@ section { } } +@media (hover: hover) and (min-width: 769px) { + .service-card:hover .service-icon-bubble { + transform: translateY(-3px) rotate(-5deg) scale(1.04); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.05), + 0 16px 28px rgba(17, 20, 24, 0.14); + } + + .service-card:nth-child(even):hover .service-icon-bubble { + transform: translateY(-3px) rotate(5deg) scale(1.04); + } +} + .service-card:active { transform: translateY(-1px) scale(0.992); } diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 5b9b1a2..db1c11d 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -34,7 +34,7 @@
diff --git a/src/routes/[slug]/+page.server.ts b/src/routes/[slug]/+page.server.ts index e89398f..5434f75 100644 --- a/src/routes/[slug]/+page.server.ts +++ b/src/routes/[slug]/+page.server.ts @@ -7,6 +7,10 @@ export async function load({ params }) { throw redirect(301, '/about'); } + if (params.slug === 'booking') { + throw redirect(301, '/contact-us'); + } + const slug = params.slug as StaticPageSlug; const page = staticPages[slug]; diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte index a2ce197..544be33 100644 --- a/src/routes/[slug]/+page.svelte +++ b/src/routes/[slug]/+page.svelte @@ -184,7 +184,7 @@ {:else if data.slug === 'privacy-policy'} -{:else if data.slug === 'booking'} +{:else if data.slug === 'contact-us'} {:else}
diff --git a/src/routes/[slug]/slug-page.server.test.ts b/src/routes/[slug]/slug-page.server.test.ts index fde3cad..1bea88d 100644 --- a/src/routes/[slug]/slug-page.server.test.ts +++ b/src/routes/[slug]/slug-page.server.test.ts @@ -24,6 +24,13 @@ describe('static slug page server load', () => { }); }); + it('redirects the legacy booking slug to /contact-us', async () => { + await expect(load({ params: { slug: 'booking' } } as never)).rejects.toMatchObject({ + status: 301, + location: '/contact-us' + }); + }); + it('throws a 404 for unknown slugs', async () => { await expect(load({ params: { slug: 'missing-page' } } as never)).rejects.toMatchObject({ status: 404 diff --git a/src/routes/[slug]/slug-page.test.ts b/src/routes/[slug]/slug-page.test.ts index 4bda782..bc36923 100644 --- a/src/routes/[slug]/slug-page.test.ts +++ b/src/routes/[slug]/slug-page.test.ts @@ -10,7 +10,7 @@ describe('static slug route page', () => { ['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'], ['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'], ['about', 'Who we are'], - ['booking', "Fill in the form below and we'll be in touch to arrange a free introduction."], + ['contact-us', "Fill in the form below and we'll be in touch to arrange a free introduction."], ['terms-and-conditions', '1. Application of Terms'], ['privacy-policy', 'How we collect your information'] ] as const)('renders the %s page branch', (slug, expectedText) => { diff --git a/src/routes/layout.test.ts b/src/routes/layout.test.ts index 7409518..94cd63b 100644 --- a/src/routes/layout.test.ts +++ b/src/routes/layout.test.ts @@ -29,7 +29,7 @@ describe('root layout navigation behavior', () => { navigateHandler({ from: { url: new URL('https://www.goodwalk.co.nz/about') }, - to: { url: new URL('https://www.goodwalk.co.nz/booking') } + to: { url: new URL('https://www.goodwalk.co.nz/contact-us') } }); expect(disableScrollHandling).toHaveBeenCalledTimes(1); diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index a6a126b..b353357 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -8,7 +8,7 @@ const routes = [ '/puppy-visits', '/our-pricing', '/about', - '/booking', + '/contact-us', '/terms-and-conditions', '/privacy-policy' ]; diff --git a/src/routes/sitemap.xml/sitemap.test.ts b/src/routes/sitemap.xml/sitemap.test.ts index ad4548d..a86b28a 100644 --- a/src/routes/sitemap.xml/sitemap.test.ts +++ b/src/routes/sitemap.xml/sitemap.test.ts @@ -15,6 +15,7 @@ describe('sitemap endpoint', () => { expect(response.headers.get('content-type')).toBe('application/xml; charset=utf-8'); expect(body).toContain('https://www.goodwalk.co.nz/'); + expect(body).toContain('https://www.goodwalk.co.nz/contact-us'); expect(body).toContain('https://www.goodwalk.co.nz/privacy-policy'); expect(body).toContain('2026-05-01'); expect(body.match(//g)).toHaveLength(9); diff --git a/vite.config.ts b/vite.config.ts index 2749a49..500a5c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,20 +1,213 @@ +import { readFileSync } from 'node:fs'; +import { connect } from 'node:net'; +import type { AddressInfo } from 'node:net'; import { sveltekit } from '@sveltejs/kit/vite'; import { svelteTesting } from '@testing-library/svelte/vite'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; +import { createLogger, type Plugin, type ProxyOptions } from 'vite'; -export default defineConfig({ - plugins: [svelteTesting({ autoCleanup: false }), sveltekit()], - server: { - proxy: { - '/api/submit': { - target: 'http://localhost:8000', - rewrite: (path) => path.replace(/^\/api\/submit/, '/submit') - } - } - }, - test: { - environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], - include: ['src/**/*.test.ts'] +const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + name: string; + version: string; + devDependencies?: Record; +}; + +const appName = packageJson.name; +const appVersion = packageJson.version; +const viteVersion = packageJson.devDependencies?.vite ?? 'unknown'; +const svelteKitVersion = packageJson.devDependencies?.['@sveltejs/kit'] ?? 'unknown'; +const submitProxyPath = '/api/submit'; +const submitProxyTarget = 'http://localhost:8000'; +const submitProxyDestination = `${submitProxyTarget}/submit`; + +function resolvePort(target: URL) { + if (target.port) { + return Number(target.port); } + + return target.protocol === 'https:' ? 443 : 80; +} + +function probeTcpPort(target: string, timeoutMs = 1200) { + const url = new URL(target); + + return new Promise<{ ok: true } | { ok: false; error: NodeJS.ErrnoException }>((resolve) => { + const socket = connect({ + host: url.hostname, + port: resolvePort(url) + }); + + let settled = false; + + const finish = (result: { ok: true } | { ok: false; error: NodeJS.ErrnoException }) => { + if (settled) { + return; + } + + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.once('connect', () => finish({ ok: true })); + socket.once('error', (error: NodeJS.ErrnoException) => finish({ ok: false, error })); + socket.setTimeout(timeoutMs, () => + finish({ + ok: false, + error: Object.assign(new Error(`Timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT' }) + }) + ); + }); +} + +function formatProxyError(error: NodeJS.ErrnoException) { + const code = error.code ?? 'UNKNOWN'; + const hint = + code === 'ECONNREFUSED' + ? `Mail API is not reachable at ${submitProxyTarget}. Start the Python API before submitting the form.` + : code === 'ETIMEDOUT' + ? `Mail API at ${submitProxyTarget} did not respond in time.` + : 'The booking request could not be forwarded to the Mail API.'; + + return { + code, + hint + }; +} + +function createGoodwalkLogger() { + const logger = createLogger(); + const baseError = logger.error.bind(logger); + + logger.error = (msg, options) => { + if ( + typeof msg === 'string' && + msg.includes('[vite] http proxy error') && + (msg.includes('/submit') || msg.includes(submitProxyPath)) + ) { + return; + } + + baseError(msg, options); + }; + + return logger; +} + +function goodwalkDevServerPlugin(logger: ReturnType): Plugin { + let printedStartup = false; + + const printStartup = async (serverHost: string | undefined, address: AddressInfo | string | null) => { + if (printedStartup) { + return; + } + + printedStartup = true; + + const localUrls = server.resolvedUrls?.local ?? []; + const networkUrls = server.resolvedUrls?.network ?? []; + const reachable = await probeTcpPort(submitProxyTarget); + const backendStatus = reachable.ok + ? `ready at ${submitProxyTarget}` + : `${formatProxyError(reachable.error).code} at ${submitProxyTarget}`; + + logger.info(''); + logger.info(`[goodwalk] ${appName} dev server ready`); + logger.info(`[goodwalk] Version ${appVersion} | Node ${process.version} | Vite ${viteVersion} | SvelteKit ${svelteKitVersion}`); + + if (localUrls.length) { + logger.info(`[goodwalk] Local: ${localUrls.join(', ')}`); + } + + if (networkUrls.length) { + logger.info(`[goodwalk] Network: ${networkUrls.join(', ')}`); + } + + if (!localUrls.length && address && typeof address !== 'string') { + const host = serverHost && serverHost !== '0.0.0.0' ? serverHost : 'localhost'; + logger.info(`[goodwalk] Local: http://${host}:${address.port}/`); + } + + logger.info(`[goodwalk] Booking proxy: ${submitProxyPath} -> ${submitProxyDestination}`); + + if (reachable.ok) { + logger.info(`[goodwalk] Mail API: ${backendStatus}`); + } else { + logger.warn(`[goodwalk] Mail API: ${backendStatus}`); + logger.warn(`[goodwalk] Hint: ${formatProxyError(reachable.error).hint}`); + } + + logger.info(''); + }; + + let server: import('vite').ViteDevServer; + + return { + name: 'goodwalk-dev-server-logging', + configureServer(devServer) { + server = devServer; + + devServer.httpServer?.once('listening', () => { + const address = devServer.httpServer?.address() ?? null; + const configuredHost = + typeof devServer.config.server.host === 'string' ? devServer.config.server.host : undefined; + + void printStartup(configuredHost, address); + }); + } + }; +} + +export default defineConfig(() => { + const logger = createGoodwalkLogger(); + + const submitProxy: ProxyOptions = { + target: submitProxyTarget, + rewrite: (path) => path.replace(/^\/api\/submit/, '/submit'), + configure(proxy) { + proxy.on('error', (error: NodeJS.ErrnoException, req, res) => { + const { code, hint } = formatProxyError(error); + const requestPath = req.url ?? submitProxyPath; + const requestMethod = req.method ?? 'POST'; + + logger.error( + [ + '', + '[goodwalk] Booking proxy failed', + `[goodwalk] Request: ${requestMethod} ${requestPath}`, + `[goodwalk] Target: ${submitProxyDestination}`, + `[goodwalk] Code: ${code}`, + `[goodwalk] Message: ${error.message}`, + `[goodwalk] Hint: ${hint}`, + '' + ].join('\n') + ); + + if (res && 'writeHead' in res && !res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Mail API unavailable', + detail: hint + }) + ); + } + }); + } + }; + + return { + customLogger: logger, + plugins: [goodwalkDevServerPlugin(logger), svelteTesting({ autoCleanup: false }), sveltekit()], + server: { + proxy: { + [submitProxyPath]: submitProxy + } + }, + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + include: ['src/**/*.test.ts'] + } + }; });