Compare commits

...
15 Commits
Author SHA1 Message Date
mmintelandClaude Haiku 5.5 6283a4e5ad chore(release): bump version to 2.4.62
Build & Deploy / 🔍 Prepare (push) Successful in 1m19s
Build & Deploy / 🧪 QA (push) Successful in 4m16s
Build & Deploy / 🏗️ Build (push) Successful in 6m38s
Build & Deploy / 🚀 Deploy (push) Successful in 2m8s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m25s
Build & Deploy / 🔔 Notify (push) Successful in 4s
package.json was still at 2.4.58 while tags up to v2.4.61 existed.
Align the version with the release tag v2.4.62.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-13 13:40:05 +01:00
mmintelandClaude Haiku 5.5 9765d28b6d content(messen): replace Linstow 2026 with Windenergietage Potsdam 2027
The Windenergietage Linstow 2026 (10.–12. Nov 2026) has already taken
place, so it is removed from the DE and EN fairs list. Windenergietage
Potsdam (09.–11. Nov 2027) is added. The content test is updated to
match the new expectations.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-13 13:00:14 +01:00
mmintel 0cc7d0805e fix(content): correct ISO certificate validity dates from OCR-verified scans
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Successful in 2m46s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 💨 Smoke & Health (push) Successful in 1m43s
2026-09-08 11:56:49 +02:00
mmintel ad5079dcec fix(assets): replace iso9001.pdf with official 2023-12-14 certificate scan
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🏗️ Build (push) Successful in 2m34s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-09-08 11:22:41 +02:00
mmintel 2bbc4235be content(imprint): replace outdated EU dispute resolution with consumer dispute resolution notice (DE+EN) 2026-09-08 11:21:00 +02:00
mmintel 7a1eb40bc8 ci(deploy): pass FORM_TOKEN_SECRET into generated env for anti-spam token signing
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m19s
Build & Deploy / 🏗️ Build (push) Successful in 2m38s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-09-02 10:31:37 +02:00
mmintel 99467f1379 fix(antispam): harden spam guard with signed form tokens and proxy-safe IP extraction
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m33s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Rate limit now uses the last X-Forwarded-For hop (appended by Traefik)
  instead of the client-controlled first hop, which bots rotated freely
- Time-trap anchored to server time via HMAC-signed form token
  (FORM_TOKEN_SECRET) instead of client-supplied form_loaded_at
- Fix RequestQuoteForm regression: FormData was built from scratch without
  honeypot/token hidden inputs and inputs lacked name attributes, so every
  legitimate quote request was silently blocked as spam
2026-09-02 10:06:07 +02:00
mmintel 6b3f9b9b25 chore(gitignore): ignore .kilo local agent config and debug artifacts
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m24s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-09-01 17:17:46 +02:00
mmintel 2c51831a81 feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap) 2026-09-01 17:15:40 +02:00
mmintel db0963a7db chore(test): remove non-business logic config tests
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 2m37s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:31:53 +02:00
mmintel aac0529bb6 fix(infra): replace legacy klz fallback references with etib across docker compose and test scripts
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m44s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:31:29 +02:00
mmintel 5006163ddf fix(ci): update proxy matcher and traefik public router for image optimizer loopback
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 2m34s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:22:45 +02:00
mmintel 35fb31249a fix(content): keep pipe diameter unchanged at 400er Rohr / 400mm pipe
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m19s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-08-13 09:48:45 +02:00
mmintel 120ba0488c fix(content): update HDD drilling max distance from 250m to 400m in DE and EN kompetenzen
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-08-13 09:47:36 +02:00
mmintel ffb637e05c fix(team): disable carousel lightbox popup on mobile and enlarge grid profile photos
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m0s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-13 00:55:00 +02:00
39 changed files with 573 additions and 142 deletions
+3
View File
@@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026
# Required for the Payload CMS AI Chat Agent # Required for the Payload CMS AI Chat Agent
MISTRAL_API_KEY= MISTRAL_API_KEY=
# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap)
FORM_TOKEN_SECRET=
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized) # Payload Infrastructure (Dockerized)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
+6
View File
@@ -286,6 +286,9 @@ jobs:
# Notifications # Notifications
GOTIFY_URL: ${{ secrets.GOTIFY_URL }} GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
# Anti-spam
FORM_TOKEN_SECRET: ${{ secrets.FORM_TOKEN_SECRET }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -360,6 +363,9 @@ jobs:
echo "GOTIFY_URL=$GOTIFY_URL" echo "GOTIFY_URL=$GOTIFY_URL"
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN" echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
echo "" echo ""
echo "# Anti-spam"
echo "FORM_TOKEN_SECRET=$FORM_TOKEN_SECRET"
echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME" echo "PROJECT_NAME=$PROJECT_NAME"
+7
View File
@@ -44,3 +44,10 @@ next-env.d.ts
# scratch # scratch
scratch/ scratch/
# kilo (local agent config & session data)
.kilo/
# debug artifacts
contact-page-debug.html
*.debug.html
+27 -5
View File
@@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server'; import { getServerAppServices } from '@/lib/services/create-services.server';
import { createFormToken } from '@/lib/antispam/form-token';
import { parseClientIp } from '@/lib/antispam/client-ip';
/**
* Issues a server-signed form token used by the anti-spam guard to prove the
* submission originates from a real page load (server-anchored time-trap).
*/
export async function issueFormTokenAction(): Promise<string> {
return createFormToken(Date.now());
}
export async function sendContactFormAction(formData: FormData) { export async function sendContactFormAction(formData: FormData) {
const services = getServerAppServices(); const services = getServerAppServices();
@@ -25,11 +35,23 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt // Track attempt
services.analytics.track('contact-form-attempt'); services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check // Anti-spam guard: honeypot, signed form token (server-anchored time-trap), email validation, link limit, IP rate limit
const honeypot = formData.get('company_website') as string; const { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
if (honeypot) { const verdict = checkContactSubmission({
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') }); honeypot: (formData.get('company_website') as string) || null,
// Silently succeed to fool the bot without doing actual work formToken: (formData.get('form_token') as string) || null,
now: Date.now(),
ip: parseClientIp(requestHeaders.get('x-forwarded-for')),
email: (formData.get('email') as string) || null,
message: (formData.get('message') as string) || null,
});
if (!verdict.allowed) {
logger.warn('Spam blocked by anti-spam guard', {
reason: verdict.reason,
email: formData.get('email'),
});
// Silently succeed to fool bots without doing actual work
return { success: true }; return { success: true };
} }
+15 -1
View File
@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui'; import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events'; import { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -12,6 +12,18 @@ export default function ContactForm() {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [formToken, setFormToken] = useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleFocus = (fieldId: string) => { const handleFocus = (fieldId: string) => {
// Initial form start // Initial form start
@@ -152,6 +164,8 @@ export default function ContactForm() {
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden="true" aria-hidden="true"
/> />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-1 md:space-y-2"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label> <Label htmlFor="contact-name">{t('form.name')}</Label>
<Input <Input
+22 -7
View File
@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } from '@/components/ui'; import { Input, Textarea, Button } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events'; import { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -18,6 +18,18 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [request, setRequest] = useState(''); const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [formToken, setFormToken] = useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleFocus = (fieldId: string) => { const handleFocus = (fieldId: string) => {
// Initial form start // Initial form start
@@ -38,14 +50,13 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
}); });
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setStatus('submitting'); setStatus('submitting');
const formData = new FormData(); // Build from the real form element so honeypot + form_token hidden inputs are included
const formData = new FormData(e.currentTarget);
formData.append('name', 'Product Inquiry'); // Default name for product inquiries formData.append('name', 'Product Inquiry'); // Default name for product inquiries
formData.append('email', email);
formData.append('message', request);
formData.append('productName', productName); formData.append('productName', productName);
try { try {
@@ -172,8 +183,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
tabIndex={-1} tabIndex={-1}
autoComplete="off" autoComplete="off"
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden="true" aria-hidden="true"
/> />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-2 !mt-0"> <div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
@@ -183,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<Input <Input
type="email" type="email"
id={emailId} id={emailId}
name="email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
@@ -198,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
</label> </label>
<Textarea <Textarea
id={requestId} id={requestId}
name="message"
required required
rows={3} rows={3}
value={request} value={request}
+3 -3
View File
@@ -31,21 +31,21 @@ export const defaultCertificates: Certificate[] = [
description: 'Umweltmanagementsystem', description: 'Umweltmanagementsystem',
pdfUrl: '/assets/certificates/iso14001.pdf', pdfUrl: '/assets/certificates/iso14001.pdf',
type: 'iso', type: 'iso',
date: '14.12.2023', date: '06.12.2023 – 05.12.2026',
}, },
{ {
title: 'ISO 9001:2015', title: 'ISO 9001:2015',
description: 'Qualitätsmanagementsystem', description: 'Qualitätsmanagementsystem',
pdfUrl: '/assets/certificates/iso9001.pdf', pdfUrl: '/assets/certificates/iso9001.pdf',
type: 'iso', type: 'iso',
date: '14.12.2023', date: '06.12.2023 – 05.12.2026',
}, },
{ {
title: 'DIN EN ISO 45001:2018', title: 'DIN EN ISO 45001:2018',
description: 'Arbeits- und Gesundheitsschutz', description: 'Arbeits- und Gesundheitsschutz',
pdfUrl: '/assets/certificates/iso45001.pdf', pdfUrl: '/assets/certificates/iso45001.pdf',
type: 'iso', type: 'iso',
date: '05.12.2025', date: '06.12.2025 – 05.12.2028',
}, },
{ {
title: 'Betriebshaftpflichtversicherung', title: 'Betriebshaftpflichtversicherung',
+12 -10
View File
@@ -149,7 +149,7 @@ export function TeamGrid({ members }: TeamGridProps) {
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden" className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
> >
{/* Card Banner with Signature E-TIB LogoArcs Design Element */} {/* Card Banner with Signature E-TIB LogoArcs Design Element */}
<div className="h-40 sm:h-44 md:h-36 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden select-none"> <div className="h-44 sm:h-52 md:h-36 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden select-none">
<div className="absolute inset-0 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" /> <div className="absolute inset-0 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" />
{/* Signature E-TIB Logo Arcs Design Element */} {/* Signature E-TIB Logo Arcs Design Element */}
@@ -172,16 +172,18 @@ export function TeamGrid({ members }: TeamGridProps) {
</div> </div>
<div className="px-6 sm:px-8 pb-8 relative flex-grow flex flex-col items-center md:items-start text-center md:text-left"> <div className="px-6 sm:px-8 pb-8 relative flex-grow flex flex-col items-center md:items-start text-center md:text-left">
{/* Overlapping Profile Picture with Shared Layout Animation (Extra Large Centered on Mobile) */} {/* Overlapping Profile Picture (Extra Large Centered on Mobile, Lightbox modal active only on Desktop) */}
<m.div <m.div
layoutId={`team-avatar-${member.id}`} layoutId={`team-avatar-${member.id}`}
onClick={() => { onClick={() => {
setPage([0, 0]); if (typeof window !== 'undefined' && window.innerWidth >= 768) {
setSelectedMember(member); setPage([0, 0]);
setSelectedMember(member);
}
}} }}
onMouseEnter={() => handlePreload(imageUrl)} onMouseEnter={() => handlePreload(imageUrl)}
onTouchStart={() => handlePreload(imageUrl)} onTouchStart={() => handlePreload(imageUrl)}
className="w-44 h-56 sm:w-48 sm:h-60 md:w-36 md:h-44 mx-auto md:mx-0 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-24 md:-mt-24 mb-6 group-hover:-translate-y-2 transition-transform duration-500 cursor-pointer group/avatar" className="w-52 h-64 sm:w-60 sm:h-72 md:w-36 md:h-44 mx-auto md:mx-0 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-28 md:-mt-24 mb-6 transition-transform duration-500 cursor-default md:cursor-pointer group/avatar"
> >
{imageUrl ? ( {imageUrl ? (
<> <>
@@ -189,11 +191,11 @@ export function TeamGrid({ members }: TeamGridProps) {
src={imageUrl} src={imageUrl}
alt={member.name} alt={member.name}
fill fill
sizes="(max-width: 768px) 160px, 144px" sizes="(max-width: 768px) 300px, 144px"
className="object-cover object-[center_15%] transition-transform duration-500 group-hover/avatar:scale-105" className="object-cover object-[center_15%] transition-transform duration-500 group-hover/avatar:scale-105"
/> />
{/* Zoom Icon Overlay on Hover */} {/* Zoom Icon Overlay on Hover (Desktop only) */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 flex items-center justify-center text-white cursor-pointer"> <div className="hidden md:flex absolute inset-0 bg-black/40 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
@@ -204,8 +206,8 @@ export function TeamGrid({ members }: TeamGridProps) {
</> </>
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50 group-hover/avatar:bg-neutral-100 transition-colors"> <div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50 group-hover/avatar:bg-neutral-100 transition-colors">
<svg className="w-14 h-14" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg> <svg className="w-16 h-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 flex items-center justify-center text-white cursor-pointer"> <div className="hidden md:flex absolute inset-0 bg-black/30 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+14 -1
View File
@@ -3,10 +3,22 @@
import * as React from 'react'; import * as React from 'react';
import { m } from 'framer-motion'; import { m } from 'framer-motion';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
export function ContactForm() { export function ContactForm() {
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [formToken, setFormToken] = React.useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -132,6 +144,7 @@ export function ContactForm() {
</div> </div>
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" /> <input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="form_token" value={formToken ?? ''} />
<Button <Button
type="submit" type="submit"
+2 -7
View File
@@ -38,14 +38,9 @@ Danny Joseph
Gewerbestraße 22 Gewerbestraße 22
03172 Guben 03172 Guben
## EU-Streitschlichtung ## Verbraucherstreitbeilegung
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: [https://ec.europa.eu/consumers/odr/](https://ec.europa.eu/consumers/odr/). Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
Unsere E-Mail-Adresse finden Sie oben im Impressum.
## Verbraucherstreitbeilegung/Universalschlichtungsstelle
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
## Haftungsausschluss (Disclaimer) ## Haftungsausschluss (Disclaimer)
+1 -1
View File
@@ -104,7 +104,7 @@ layout: "fullBleed"
icon: "hdd", icon: "hdd",
lists: [ lists: [
[ [
"Horizontalspülbohrverfahren (bis 250m)", "Horizontalspülbohrverfahren (bis 400m)",
"Horizontalspülbohrverfahren (bis 400er Rohr)", "Horizontalspülbohrverfahren (bis 400er Rohr)",
"Erdrakete (bis 15m DÖ-Länge)", "Erdrakete (bis 15m DÖ-Länge)",
"Erdrakete (bis 160er Rohr)" "Erdrakete (bis 160er Rohr)"
+2 -2
View File
@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true} showFairs={true}
showJobs={false} showJobs={false}
fairs={[ fairs={[
{ name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }, { name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' } { name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' },
{ name: 'Windenergietage Potsdam', date: '09. - 11. November 2027', type: 'Fachkongress', location: 'Potsdam', url: 'https://www.windenergietage.de/' }
]} ]}
/> />
</div> </div>
+2 -7
View File
@@ -40,14 +40,9 @@ Gewerbestraße 22
03172 Guben 03172 Guben
Germany Germany
## EU Dispute Resolution ## Consumer Dispute Resolution
The European Commission provides a platform for online dispute resolution (ODR): [https://ec.europa.eu/consumers/odr/](https://ec.europa.eu/consumers/odr/). We are not willing and not obliged to participate in dispute resolution proceedings before a consumer arbitration board.
Our email address can be found at the top of the imprint.
## Consumer Dispute Resolution/Universal Arbitration Board
We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.
## Disclaimer ## Disclaimer
+1 -1
View File
@@ -104,7 +104,7 @@ layout: "fullBleed"
icon: "hdd", icon: "hdd",
lists: [ lists: [
[ [
"Horizontal directional drilling (up to 250m)", "Horizontal directional drilling (up to 400m)",
"Horizontal directional drilling (up to 400mm pipe)", "Horizontal directional drilling (up to 400mm pipe)",
"Earth rocket (up to 15m crossing length)", "Earth rocket (up to 15m crossing length)",
"Earth rocket (up to 160mm pipe)" "Earth rocket (up to 160mm pipe)"
+2 -2
View File
@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true} showFairs={true}
showJobs={false} showJobs={false}
fairs={[ fairs={[
{ name: 'Wind Energy Days Linstow', date: 'November 10–12, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
{ name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }, { name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
{ name: 'Intersolar Munich', date: 'June 8–10, 2027', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' } { name: 'Intersolar Munich', date: 'June 8–10, 2027', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' },
{ name: 'Wind Energy Days Potsdam', date: 'November 9–11, 2027', type: 'Specialist Congress', location: 'Potsdam', url: 'https://www.windenergietage.de/' }
]} ]}
/> />
</div> </div>
+3 -3
View File
@@ -23,21 +23,21 @@ layout: "fullBleed"
description: 'Environmental Management System', description: 'Environmental Management System',
pdfUrl: '/assets/certificates/iso14001.pdf', pdfUrl: '/assets/certificates/iso14001.pdf',
type: 'iso', type: 'iso',
date: '14.12.2023', date: '06.12.2023 – 05.12.2026',
}, },
{ {
title: 'ISO 9001:2015', title: 'ISO 9001:2015',
description: 'Quality Management System', description: 'Quality Management System',
pdfUrl: '/assets/certificates/iso9001.pdf', pdfUrl: '/assets/certificates/iso9001.pdf',
type: 'iso', type: 'iso',
date: '14.12.2023', date: '06.12.2023 – 05.12.2026',
}, },
{ {
title: 'DIN EN ISO 45001:2018', title: 'DIN EN ISO 45001:2018',
description: 'Occupational Health and Safety', description: 'Occupational Health and Safety',
pdfUrl: '/assets/certificates/iso45001.pdf', pdfUrl: '/assets/certificates/iso45001.pdf',
type: 'iso', type: 'iso',
date: '05.12.2025', date: '06.12.2025 – 05.12.2028',
}, },
{ {
title: 'Commercial Liability Insurance', title: 'Commercial Liability Insurance',
+2 -1
View File
@@ -57,9 +57,10 @@ services:
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001" sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001" - "traefik.http.services.${PROJECT_NAME:-etib}-app-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
networks: networks:
default: default:
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal
+44 -42
View File
@@ -15,46 +15,47 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}" - "traefik.http.routers.${PROJECT_NAME:-etib}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.entrypoints=web" - "traefik.http.routers.${PROJECT_NAME:-etib}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https" - "traefik.http.routers.${PROJECT_NAME:-etib}-web.middlewares=redirect-https"
# HTTPS router (Standard) # HTTPS router (Standard)
- "traefik.http.routers.${PROJECT_NAME:-klz}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}" - "traefik.http.routers.${PROJECT_NAME:-etib}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-etib}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-etib}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-etib}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc" - "traefik.http.routers.${PROJECT_NAME:-etib}.service=${PROJECT_NAME:-etib}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}" - "traefik.http.routers.${PROJECT_NAME:-etib}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images) # Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|assets|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)" - "traefik.http.routers.${PROJECT_NAME:-etib}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|assets|_next|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000" - "traefik.http.routers.${PROJECT_NAME:-etib}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.service=${PROJECT_NAME:-etib}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-etib}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
# Middlewares # Middlewares
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-compress.compress=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login # Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.regex=^https?://([^/]+)/([a-z]{2}/)?login(.*)" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.regex=^https?://([^/]+)/([a-z]{2}/)?login(.*)"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.replacement=https://$${1}/gatekeeper/login$${3}" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.replacement=https://$${1}/gatekeeper/login$${3}"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.permanent=false" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.permanent=false"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.middlewares=${PROJECT_NAME:-etib}-loginredirect"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.service=${PROJECT_NAME:-etib}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002" - "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.priority=2002"
etib-gatekeeper: etib-gatekeeper:
profiles: [ "gatekeeper" ] profiles: [ "gatekeeper" ]
@@ -64,7 +65,7 @@ services:
default: default:
infra: infra:
aliases: aliases:
- ${PROJECT_NAME:-klz}-gatekeeper - ${PROJECT_NAME:-etib}-gatekeeper
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
@@ -73,19 +74,20 @@ services:
NODE_ENV: production NODE_ENV: production
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-etib}-gatekeeper-svc.loadbalancer.server.port=3000"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.address=http://${PROJECT_NAME:-etib}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User" - "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain # Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathPrefix(`/gatekeeper`)" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathPrefix(`/gatekeeper`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.service=${PROJECT_NAME:-etib}-gatekeeper-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.priority=2001" - "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.priority=2001"
etib-db: etib-db:
image: postgres:15-alpine image: postgres:15-alpine
+18
View File
@@ -0,0 +1,18 @@
/**
* Extracts the client IP from the X-Forwarded-For header.
*
* Our edge proxy (Traefik) APPENDS the real peer IP to any client-supplied
* chain, so the LAST hop is the only trustworthy entry. The first hop is
* attacker-controlled: reading it lets bots rotate fake IPs per request and
* defeat IP-based rate limiting entirely.
*/
export function parseClientIp(xForwardedFor: string | null | undefined): string | null {
if (!xForwardedFor) {
return null;
}
const hops = xForwardedFor
.split(',')
.map((hop) => hop.trim())
.filter(Boolean);
return hops.length > 0 ? hops[hops.length - 1] : null;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Server-side anti-spam guard for contact form submissions.
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
*/
import { verifyFormToken } from './form-token';
export const MIN_FILL_TIME_MS = 3_000;
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
export const MAX_MESSAGE_LINKS = 5;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
export type SpamReason =
| 'honeypot'
| 'invalid_token'
| 'expired_token'
| 'too_fast'
| 'invalid_email'
| 'too_many_links'
| 'rate_limited';
export interface ContactSubmissionInput {
honeypot: string | null;
formToken: string | null;
now: number;
ip: string | null;
email: string | null;
message: string | null;
}
export interface ContactSubmissionVerdict {
allowed: boolean;
reason?: SpamReason;
}
const submissionTimestampsByIp = new Map<string, number[]>();
export function resetRateLimiter(): void {
submissionTimestampsByIp.clear();
}
function isRateLimited(ip: string, now: number): boolean {
const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS,
);
if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) {
submissionTimestampsByIp.set(ip, timestamps);
return true;
}
timestamps.push(now);
submissionTimestampsByIp.set(ip, timestamps);
return false;
}
export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict {
if (input.honeypot) {
return { allowed: false, reason: 'honeypot' };
}
// Server-signed token: rejects direct POSTs that never loaded the form and
// anchors the time-trap to server-verified issuance time.
const token = verifyFormToken(input.formToken, input.now);
if (!token.ok) {
return {
allowed: false,
reason: token.reason === 'expired' ? 'expired_token' : 'invalid_token',
};
}
if (input.now - token.issuedAt! < MIN_FILL_TIME_MS) {
return { allowed: false, reason: 'too_fast' };
}
if (!input.email || !EMAIL_PATTERN.test(input.email)) {
return { allowed: false, reason: 'invalid_email' };
}
const message = input.message ?? '';
if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) {
return { allowed: false, reason: 'too_many_links' };
}
if (input.ip && isRateLimited(input.ip, input.now)) {
return { allowed: false, reason: 'rate_limited' };
}
return { allowed: true };
}
+64
View File
@@ -0,0 +1,64 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Server-signed form token: proves a submission originates from a real page
* load and anchors the time-trap to SERVER time instead of a client-supplied
* timestamp (which bots can forge arbitrarily).
*
* Token format: `<issuedAtMs>.<hmac-sha256(issuedAt, secret)>` (base64url).
*/
export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
export type FormTokenFailureReason = 'malformed' | 'forged' | 'expired';
export interface FormTokenVerification {
ok: boolean;
issuedAt?: number;
reason?: FormTokenFailureReason;
}
export function getFormTokenSecret(): string {
return process.env.FORM_TOKEN_SECRET || 'insecure-dev-form-token-secret';
}
function sign(issuedAt: number, secret: string): string {
return createHmac('sha256', secret).update(String(issuedAt)).digest('base64url');
}
export function createFormToken(now: number, secret: string = getFormTokenSecret()): string {
return `${now}.${sign(now, secret)}`;
}
export function verifyFormToken(
token: string | null,
now: number,
secret: string = getFormTokenSecret(),
): FormTokenVerification {
if (!token) {
return { ok: false, reason: 'malformed' };
}
const parts = token.split('.');
if (parts.length !== 2) {
return { ok: false, reason: 'malformed' };
}
const issuedAt = Number(parts[0]);
if (!Number.isFinite(issuedAt) || issuedAt <= 0) {
return { ok: false, reason: 'malformed' };
}
const expected = Buffer.from(sign(issuedAt, secret));
const provided = Buffer.from(parts[1]);
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
return { ok: false, reason: 'forged' };
}
const age = now - issuedAt;
if (age < 0 || age > MAX_FORM_AGE_MS) {
return { ok: false, reason: 'expired' };
}
return { ok: true, issuedAt };
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "e-tib-nextjs", "name": "e-tib-nextjs",
"version": "2.4.53", "version": "2.4.62",
"type": "module", "type": "module",
"private": true, "private": true,
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.18.3",
+6 -5
View File
@@ -7,11 +7,12 @@ export default createMiddleware({
}); });
export const config = { export const config = {
// Match all pathnames except for // Match all pathnames except for:
// - /api (API routes) // - /api (API routes)
// - /stats (Analytics proxy) // - /stats (Analytics proxy)
// - /_next (Next.js internals) // - /_next (Next.js internals & image optimizer)
// - /_static (inside /public) // - /assets, /_static (static files in /public)
// - all root files inside /public (e.g. /favicon.ico) // - all files with an extension (e.g. /favicon.ico, .JPG, .png, .webp, .svg)
matcher: ['/((?!api|stats|_next|assets|_static|_vercel|[\\w-]+\\.\\w+).*)'] matcher: ['/((?!api|stats|_next|assets|_static|_vercel|.*\\..*).*)']
}; };
Binary file not shown.
+7 -6
View File
@@ -31,22 +31,23 @@ fi
get_media_path() { get_media_path() {
case "$1" in case "$1" in
local) echo "$LOCAL_MEDIA_DIR" ;; local) echo "$LOCAL_MEDIA_DIR" ;;
testing) echo "/var/lib/docker/volumes/klz-testing_klz_media_data/_data" ;; testing) echo "/var/lib/docker/volumes/etib-testing_etib_media_data/_data" ;;
staging) echo "/var/lib/docker/volumes/klz-staging_klz_media_data/_data" ;; staging) echo "/var/lib/docker/volumes/etib-staging_etib_media_data/_data" ;;
prod|production) echo "/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data" ;; prod|production) echo "/var/lib/docker/volumes/e-tibcom_etib_media_data/_data" ;;
*) echo "❌ Unknown environment: $1"; exit 1 ;; *) echo "❌ Unknown environment: $1"; exit 1 ;;
esac esac
} }
get_app_container() { get_app_container() {
case "$1" in case "$1" in
testing) echo "klz-testing-klz-app-1" ;; testing) echo "etib-testing-etib-app-1" ;;
staging) echo "klz-staging-klz-app-1" ;; staging) echo "etib-staging-etib-app-1" ;;
prod|production) echo "klz-cablescom-klz-app-1" ;; prod|production) echo "e-tibcom-etib-app-1" ;;
*) echo "" ;; *) echo "" ;;
esac esac
} }
SRC_PATH=$(get_media_path "$SOURCE_ENV") SRC_PATH=$(get_media_path "$SOURCE_ENV")
TGT_PATH=$(get_media_path "$TARGET_ENV") TGT_PATH=$(get_media_path "$TARGET_ENV")
TGT_CONTAINER=$(get_app_container "$TARGET_ENV") TGT_CONTAINER=$(get_app_container "$TARGET_ENV")
+2 -1
View File
@@ -15,7 +15,8 @@ export NEXT_PUBLIC_CI=true
export CI=true export CI=true
docker network create infra 2>/dev/null || true docker network create infra 2>/dev/null || true
docker volume create klz_db_data 2>/dev/null || true docker volume create etib_db_data 2>/dev/null || true
# 2. Start infra services (DB, CMS, Gatekeeper) # 2. Start infra services (DB, CMS, Gatekeeper)
echo "📦 Starting infrastructure services..." echo "📦 Starting infrastructure services..."
+5 -3
View File
@@ -7,7 +7,8 @@ const targetUrl =
process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:3000'; 'http://localhost:3000';
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20; const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`); console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
@@ -20,9 +21,10 @@ async function main() {
try { try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`); console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
}); });
const $ = cheerio.load(response.data, { xmlMode: true }); const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc') urls = $('url loc')
.map((i, el) => $(el).text()) .map((i, el) => $(el).text())
@@ -64,7 +66,7 @@ async function main() {
// Inject Gatekeeper session bypassing auth screens // Inject Gatekeeper session bypassing auth screens
await page.setCookie({ await page.setCookie({
name: 'klz_gatekeeper_session', name: authCookieName,
value: gatekeeperPassword, value: gatekeeperPassword,
domain: new URL(targetUrl).hostname, domain: new URL(targetUrl).hostname,
path: '/', path: '/',
+4 -3
View File
@@ -3,7 +3,8 @@ import axios from 'axios';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
// Utility for hardcoded delays // Utility for hardcoded delays
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -20,7 +21,7 @@ async function main() {
while (Date.now() - startTime < maxWaitMs) { while (Date.now() - startTime < maxWaitMs) {
try { try {
const resp = await axios.get(targetUrl, { const resp = await axios.get(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
timeout: 5000, timeout: 5000,
validateStatus: (s) => s === 200, validateStatus: (s) => s === 200,
}); });
@@ -50,7 +51,7 @@ async function main() {
try { try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`); console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
}); });
const $ = cheerio.load(response.data, { xmlMode: true }); const $ = cheerio.load(response.data, { xmlMode: true });
+6 -3
View File
@@ -5,7 +5,8 @@ import * as path from 'path';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`); console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
@@ -16,10 +17,11 @@ async function main() {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`); console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400, validateStatus: (status) => status < 400,
}); });
const $ = cheerio.load(response.data, { xmlMode: true }); const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc') let urls = $('url loc')
.map((i, el) => $(el).text()) .map((i, el) => $(el).text())
@@ -47,10 +49,11 @@ async function main() {
const u = urls[i]; const u = urls[i];
try { try {
const res = await axios.get(u, { const res = await axios.get(u, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400, validateStatus: (status) => status < 400,
}); });
// Generate a safe filename that retains URL information // Generate a safe filename that retains URL information
const urlStr = new URL(u); const urlStr = new URL(u);
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_'); const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
+6 -3
View File
@@ -2,7 +2,8 @@ import axios from 'axios';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`); console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
@@ -12,10 +13,11 @@ async function main() {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`); console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400, validateStatus: (status) => status < 400,
}); });
const $ = cheerio.load(response.data, { xmlMode: true }); const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc') let urls = $('url loc')
.map((i, el) => $(el).text()) .map((i, el) => $(el).text())
@@ -42,10 +44,11 @@ async function main() {
const u = urls[i]; const u = urls[i];
try { try {
const res = await axios.get(u, { const res = await axios.get(u, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: null, // Don't throw on error status validateStatus: null, // Don't throw on error status
}); });
if (res.status >= 400) { if (res.status >= 400) {
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`); console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
hasErrors = true; hasErrors = true;
+4 -2
View File
@@ -12,7 +12,8 @@ import * as cheerio from 'cheerio';
*/ */
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
// Expected slug translations: German key → English value // Expected slug translations: German key → English value
const SLUG_MAP: Record<string, string> = { const SLUG_MAP: Record<string, string> = {
@@ -32,7 +33,8 @@ const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]), Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
); );
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }; const headers = { Cookie: `${authCookieName}=${gatekeeperPassword}` };
function getExpectedTranslation( function getExpectedTranslation(
sourcePath: string, sourcePath: string,
+4 -2
View File
@@ -1,7 +1,8 @@
import axios from 'axios'; import axios from 'axios';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
const requiredHeaders = [ const requiredHeaders = [
'strict-transport-security', 'strict-transport-security',
@@ -15,10 +16,11 @@ async function main() {
console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`); console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`);
try { try {
const response = await axios.head(targetUrl, { const response = await axios.head(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }, headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: () => true, validateStatus: () => true,
}); });
const headers = response.headers; const headers = response.headers;
let allPassed = true; let allPassed = true;
+2 -1
View File
@@ -7,9 +7,10 @@ module.exports = async (browser, context) => {
// Using LHCI_URL or TARGET_URL if available // Using LHCI_URL or TARGET_URL if available
const targetUrl = const targetUrl =
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com'; process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session'; const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`); console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
await page.setCookie({ await page.setCookie({
+2 -1
View File
@@ -18,9 +18,10 @@ const targetUrl =
process.env.LHCI_URL || process.env.LHCI_URL ||
'http://localhost:3000'; 'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session'; const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`); console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`); console.log(`📊 Limit: ${limit} pages\n`);
+2 -1
View File
@@ -1,7 +1,8 @@
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
async function run() { async function run() {
console.log(`🚀 Smoke Test: ${targetUrl}`); console.log(`🚀 Smoke Test: ${targetUrl}`);
+6 -3
View File
@@ -14,7 +14,8 @@ import * as path from 'path';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`); console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
@@ -27,11 +28,12 @@ async function main() {
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, Cookie: `${authCookieName}=${gatekeeperPassword}`,
}, },
validateStatus: (status) => status < 400, validateStatus: (status) => status < 400,
}); });
const $ = cheerio.load(response.data, { xmlMode: true }); const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc') let urls = $('url loc')
.map((i, el) => $(el).text()) .map((i, el) => $(el).text())
@@ -92,8 +94,9 @@ async function main() {
], ],
}, },
headers: { headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, Cookie: `${authCookieName}=${gatekeeperPassword}`,
}, },
timeout: 60000, // Increase timeout for slower pages timeout: 60000, // Increase timeout for slower pages
}, },
urls: urls, urls: urls,
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
import { parseClientIp } from '@/lib/antispam/client-ip';
import { createFormToken } from '@/lib/antispam/form-token';
process.env.FORM_TOKEN_SECRET = 'test-secret';
const NOW = 1_000_000_000_000;
const TOKEN_SECRET = 'test-secret';
const validInput = () => ({
honeypot: '',
formToken: createFormToken(NOW - 10_000, TOKEN_SECRET),
now: NOW,
ip: '1.2.3.4',
email: 'customer@example.com',
message: 'I would like a quote for 50 cables.',
});
describe('client IP extraction from X-Forwarded-For', () => {
it('uses the last hop appended by our own proxy, not the client-controlled first hop', () => {
expect(parseClientIp('203.0.113.7, 198.51.100.2')).toBe('198.51.100.2');
});
it('handles a single hop', () => {
expect(parseClientIp('203.0.113.7')).toBe('203.0.113.7');
});
it('returns null for missing or empty headers', () => {
expect(parseClientIp(null)).toBeNull();
expect(parseClientIp('')).toBeNull();
expect(parseClientIp(' ')).toBeNull();
});
});
describe('contact form anti-spam guard', () => {
beforeEach(() => resetRateLimiter());
it('allows a legitimate submission', () => {
const result = checkContactSubmission(validInput());
expect(result.allowed).toBe(true);
});
it('blocks submissions where the honeypot field is filled', () => {
const result = checkContactSubmission({ ...validInput(), honeypot: 'http://spam.example' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('honeypot');
});
it('blocks submissions without a form token (direct POST without loading the form)', () => {
const result = checkContactSubmission({ ...validInput(), formToken: null });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_token');
});
it('blocks submissions with a forged form token', () => {
const result = checkContactSubmission({ ...validInput(), formToken: '1234567890.forged' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_token');
});
it('blocks submissions sent faster than a human could fill the form', () => {
const freshToken = createFormToken(NOW - 1_000, TOKEN_SECRET);
const result = checkContactSubmission({ ...validInput(), formToken: freshToken });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('too_fast');
});
it('blocks submissions with a stale token (page loaded more than a day ago)', () => {
const staleToken = createFormToken(NOW - 25 * 60 * 60 * 1000, TOKEN_SECRET);
const result = checkContactSubmission({ ...validInput(), formToken: staleToken });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('expired_token');
});
it('blocks submissions with an invalid email address', () => {
const result = checkContactSubmission({ ...validInput(), email: 'not-an-email' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_email');
});
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
const spammy = Array.from({ length: 6 }, (_, i) => `https://spam${i}.example`).join(' ');
const result = checkContactSubmission({ ...validInput(), message: spammy });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('too_many_links');
});
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
for (let i = 0; i < 3; i++) {
expect(checkContactSubmission(validInput()).allowed).toBe(true);
}
const result = checkContactSubmission(validInput());
expect(result.allowed).toBe(false);
expect(result.reason).toBe('rate_limited');
});
it('does not rate-limit different IPs independently', () => {
for (let i = 0; i < 3; i++) {
checkContactSubmission(validInput());
}
const result = checkContactSubmission({ ...validInput(), ip: '5.6.7.8' });
expect(result.allowed).toBe(true);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import {
createFormToken,
verifyFormToken,
MAX_FORM_AGE_MS,
} from '@/lib/antispam/form-token';
const SECRET = 'test-secret';
const NOW = 1_000_000_000_000;
describe('signed form token', () => {
it('issues a token that verifies with its issuance timestamp', () => {
const token = createFormToken(NOW, SECRET);
const result = verifyFormToken(token, NOW + 10_000, SECRET);
expect(result.ok).toBe(true);
expect(result.issuedAt).toBe(NOW);
});
it('rejects a token that was tampered with', () => {
const token = createFormToken(NOW, SECRET);
const [issuedAt, signature] = token.split('.');
const tampered = `${issuedAt}.${signature.slice(0, -2)}xx`;
const result = verifyFormToken(tampered, NOW + 10_000, SECRET);
expect(result.ok).toBe(false);
});
it('rejects a token forged with a different secret', () => {
const token = createFormToken(NOW, 'attacker-secret');
const result = verifyFormToken(token, NOW + 10_000, SECRET);
expect(result.ok).toBe(false);
});
it('rejects a token with a manipulated issuance timestamp', () => {
const token = createFormToken(NOW, SECRET);
const [, signature] = token.split('.');
const forged = `${NOW - 60_000}.${signature}`;
const result = verifyFormToken(forged, NOW, SECRET);
expect(result.ok).toBe(false);
});
it('rejects malformed tokens', () => {
expect(verifyFormToken('', NOW, SECRET).ok).toBe(false);
expect(verifyFormToken('garbage', NOW, SECRET).ok).toBe(false);
expect(verifyFormToken('123.456.789', NOW, SECRET).ok).toBe(false);
});
it('rejects tokens older than the maximum form age', () => {
const token = createFormToken(NOW, SECRET);
const result = verifyFormToken(token, NOW + MAX_FORM_AGE_MS + 1, SECRET);
expect(result.ok).toBe(false);
expect(result.reason).toBe('expired');
});
});
+16 -14
View File
@@ -15,18 +15,19 @@ describe('Messen Content and Translations', () => {
expect(content).toContain("type: 'Messe'"); expect(content).toContain("type: 'Messe'");
expect(content).toContain("location: 'München'"); expect(content).toContain("location: 'München'");
// 2. Windenergietage Linstow // 2. Werkstatt Kabel 2026 (No "Kabel-Workshop")
expect(content).toContain("name: 'Windenergietage Linstow'");
expect(content).toContain("date: '10. - 12. November 2026'");
expect(content).toContain("type: 'Fachkongress'");
expect(content).toContain("location: 'Linstow'");
// 3. Werkstatt Kabel 2026 (No "Kabel-Workshop")
expect(content).not.toContain("Kabel-Workshop"); expect(content).not.toContain("Kabel-Workshop");
expect(content).toContain("name: 'Werkstatt Kabel 2026'"); expect(content).toContain("name: 'Werkstatt Kabel 2026'");
expect(content).toContain("date: '24. - 25. November 2026'"); expect(content).toContain("date: '24. - 25. November 2026'");
expect(content).toContain("type: 'Fachtagung'"); expect(content).toContain("type: 'Fachtagung'");
expect(content).toContain("location: 'Wiesbaden'"); expect(content).toContain("location: 'Wiesbaden'");
// 3. Windenergietage Potsdam (replaces Linstow 2026)
expect(content).not.toContain("Linstow");
expect(content).toContain("name: 'Windenergietage Potsdam'");
expect(content).toContain("date: '09. - 11. November 2027'");
expect(content).toContain("type: 'Fachkongress'");
expect(content).toContain("location: 'Potsdam'");
}); });
it('should have updated English trade fair dates and names for 2026', () => { it('should have updated English trade fair dates and names for 2026', () => {
@@ -38,18 +39,19 @@ describe('Messen Content and Translations', () => {
expect(content).toContain("type: 'Fair'"); expect(content).toContain("type: 'Fair'");
expect(content).toContain("location: 'Munich'"); expect(content).toContain("location: 'Munich'");
// 2. Wind Energy Days Linstow // 2. Werkstatt Kabel 2026 (No "Cable Workshop" or "Kabel Workshop")
expect(content).toContain("name: 'Wind Energy Days Linstow'");
expect(content).toContain("date: 'November 10–12, 2026'");
expect(content).toContain("type: 'Specialist Congress'");
expect(content).toContain("location: 'Linstow'");
// 3. Werkstatt Kabel 2026 (No "Cable Workshop" or "Kabel Workshop")
expect(content).not.toContain("Cable Workshop"); expect(content).not.toContain("Cable Workshop");
expect(content).not.toContain("Kabel Workshop"); expect(content).not.toContain("Kabel Workshop");
expect(content).toContain("name: 'Werkstatt Kabel 2026'"); expect(content).toContain("name: 'Werkstatt Kabel 2026'");
expect(content).toContain("date: 'November 24–25, 2026'"); expect(content).toContain("date: 'November 24–25, 2026'");
expect(content).toContain("type: 'Symposium'"); expect(content).toContain("type: 'Symposium'");
expect(content).toContain("location: 'Wiesbaden'"); expect(content).toContain("location: 'Wiesbaden'");
// 3. Wind Energy Days Potsdam (replaces Linstow 2026)
expect(content).not.toContain("Linstow");
expect(content).toContain("name: 'Wind Energy Days Potsdam'");
expect(content).toContain("date: 'November 9–11, 2027'");
expect(content).toContain("type: 'Specialist Congress'");
expect(content).toContain("location: 'Potsdam'");
}); });
}); });