fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:
- The admin accepts the entry as written (plain or obfuscated with
[at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
contact page and every email in legal texts (Impressum, Datenschutz,
Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
with a fresh random key; the browser builds the clickable mailto link
after load (components/protected-email.tsx).
Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
12675f381a
commit
2943021fe9
9 files changed
+242
-26
No files matched your search
@@ -7,7 +7,7 @@ import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
|||||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
import { fail, succeed } from "@/lib/flash";
|
import { fail, succeed } from "@/lib/flash";
|
||||||
import { z } from "zod";
|
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||||
|
|
||||||
const COMPANY_EMAIL_KEY = "company.email";
|
const COMPANY_EMAIL_KEY = "company.email";
|
||||||
|
|
||||||
@@ -51,13 +51,17 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt
|
// Die Firmen-E-Mail darf so eingetragen werden, wie sie gemeint ist —
|
||||||
// eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in
|
// auch verschleiert ("info [at] …", "info [á] …"). Gespeichert wird der
|
||||||
// mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere
|
// Eintrag unveraendert; die Website rechnet ihn in die echte Adresse um
|
||||||
// dort rechtlich angreifbar und technisch unbrauchbar — daher wird der
|
// und zeigt sie geschuetzt an (lib/contact-email.ts). Abgelehnt wird nur,
|
||||||
// ganze Speichervorgang abgelehnt statt still uebernommen.
|
// was sich in keine gueltige Adresse umrechnen laesst — sonst stuende im
|
||||||
if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) {
|
// Impressum ein Kontaktweg, der ins Leere fuehrt.
|
||||||
return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. [email protected]). Sie steht im Impressum und muss direkt nutzbar sein.");
|
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
|
||||||
|
return fail(
|
||||||
|
"/admin/inhalte",
|
||||||
|
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
|
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Container, SectionHeading } from "@/components/ui";
|
|||||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
|
import { ContactEmail } from "@/components/contact-email";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
@@ -32,22 +33,25 @@ export default async function KontaktPage() {
|
|||||||
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
|
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
|
||||||
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
|
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<a
|
{/* Vor Adress-Sammlern geschuetzt: im HTML steht nur
|
||||||
href={`mailto:${company.email}`}
|
"info [at] …", den mailto-Link baut erst der Browser. */}
|
||||||
className="group inline-flex items-center gap-3 font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
|
<ContactEmail
|
||||||
>
|
email={company.email}
|
||||||
<span className="link-draw pb-1">{company.email}</span>
|
linkClassName="group inline-flex max-w-full items-center gap-3 break-all font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
|
||||||
|
textClassName="link-draw pb-1"
|
||||||
|
trailing={
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
className="h-5 w-5 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
className="h-5 w-5 shrink-0 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.6"
|
strokeWidth="1.6"
|
||||||
>
|
>
|
||||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
}
|
||||||
|
/>
|
||||||
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
|
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
|
||||||
Platzhalternummer waere schlimmer als keine: sie sieht nach
|
Platzhalternummer waere schlimmer als keine: sie sieht nach
|
||||||
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
|
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { protectEmail } from "@/lib/contact-email";
|
||||||
|
import { ProtectedEmail } from "@/components/protected-email";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitiger Einstieg fuer jede oeffentlich angezeigte Kontakt-E-Mail:
|
||||||
|
* verschluesselt die Adresse und uebergibt nur Ersatzform + Schluesseltext an
|
||||||
|
* den Browser — die Klartext-Adresse landet weder im HTML noch in den an den
|
||||||
|
* Client uebertragenen Daten. Siehe lib/contact-email.ts.
|
||||||
|
*/
|
||||||
|
export function ContactEmail({
|
||||||
|
email,
|
||||||
|
linkClassName,
|
||||||
|
textClassName,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
linkClassName?: string;
|
||||||
|
textClassName?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { payload, fallback } = protectEmail(email);
|
||||||
|
return <ProtectedEmail payload={payload} fallback={fallback} linkClassName={linkClassName} textClassName={textClassName} trailing={trailing} />;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Container } from "@/components/ui";
|
|||||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||||
import { LegalToc } from "@/components/legal-toc";
|
import { LegalToc } from "@/components/legal-toc";
|
||||||
import { ReadingProgress } from "@/components/reading-progress";
|
import { ReadingProgress } from "@/components/reading-progress";
|
||||||
|
import { ContactEmail } from "@/components/contact-email";
|
||||||
import { extractRevision, parseLegalParagraph, sectionAnchors, tokenizeInline, type LegalBlock } from "@/lib/legal-blocks";
|
import { extractRevision, parseLegalParagraph, sectionAnchors, tokenizeInline, type LegalBlock } from "@/lib/legal-blocks";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -192,14 +193,20 @@ function Entry({ entry }: { entry: Extract<LegalBlock, { kind: "entry" }> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINK_CLASS = "text-heading underline decoration-honey/60 decoration-1 underline-offset-[0.22em] transition-colors hover:decoration-honey";
|
||||||
|
|
||||||
function Inline({ text }: { text: string }) {
|
function Inline({ text }: { text: string }) {
|
||||||
return tokenizeInline(text).map((token, i) =>
|
return tokenizeInline(text).map((token, i) =>
|
||||||
token.kind === "link" ? (
|
// E-Mail-Adressen (Impressum, Datenschutz, Widerruf) nie im Klartext ins
|
||||||
|
// HTML — Schutz vor Adress-Sammlern, siehe lib/contact-email.ts.
|
||||||
|
token.kind === "link" && token.href.startsWith("mailto:") ? (
|
||||||
|
<ContactEmail key={i} email={token.value} linkClassName={LINK_CLASS} />
|
||||||
|
) : token.kind === "link" ? (
|
||||||
<a
|
<a
|
||||||
key={i}
|
key={i}
|
||||||
href={token.href}
|
href={token.href}
|
||||||
{...(token.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
{...(token.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
className="text-heading underline decoration-honey/60 decoration-1 underline-offset-[0.22em] transition-colors hover:decoration-honey"
|
className={LINK_CLASS}
|
||||||
>
|
>
|
||||||
{token.value}
|
{token.value}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { decodeEmailPayload } from "@/lib/contact-email";
|
||||||
|
|
||||||
|
export interface ProtectedEmailProps {
|
||||||
|
/** Verschluesselte Adresse aus protectEmail() (lib/contact-email.ts). */
|
||||||
|
payload: string;
|
||||||
|
/** Lesbare Ersatzform ("info [at] wabenhain.com") fuer HTML und Besucher ohne JavaScript. */
|
||||||
|
fallback: string;
|
||||||
|
linkClassName?: string;
|
||||||
|
textClassName?: string;
|
||||||
|
/** Zusatz hinter dem Text im Link, z. B. ein Pfeil-Icon. */
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine E-Mail-Adresse, die im ausgelieferten HTML nicht im Klartext
|
||||||
|
* steht: Server-HTML und erster Render zeigen nur die Ersatzform, erst nach
|
||||||
|
* dem Laden im Browser entsteht der echte mailto-Link.
|
||||||
|
*/
|
||||||
|
export function ProtectedEmail({ payload, fallback, linkClassName, textClassName, trailing }: ProtectedEmailProps) {
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Bewusst erst nach dem Mount: im Server-HTML (das Crawler lesen) soll die
|
||||||
|
// Adresse gerade NICHT stehen, und ein abweichender erster Render wuerde
|
||||||
|
// einen Hydration-Fehler ausloesen.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Entschluesseln ist ein reiner Browser-Schritt nach dem Mount
|
||||||
|
setEmail(decodeEmailPayload(payload));
|
||||||
|
}, [payload]);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return (
|
||||||
|
<span className={linkClassName}>
|
||||||
|
<span className={textClassName}>{fallback}</span>
|
||||||
|
{trailing}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className={linkClassName}>
|
||||||
|
<span className={textClassName}>{email}</span>
|
||||||
|
{trailing}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { HoneycombPattern } from "@/components/honeycomb";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
import { Container, LinkButton } from "@/components/ui";
|
||||||
import { Logomark } from "@/components/logomark";
|
import { Logomark } from "@/components/logomark";
|
||||||
|
import { ContactEmail } from "@/components/contact-email";
|
||||||
import { footerNav } from "@/lib/site-config";
|
import { footerNav } from "@/lib/site-config";
|
||||||
import type { SocialLinks } from "@/lib/social-links";
|
import type { SocialLinks } from "@/lib/social-links";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -137,7 +138,7 @@ export async function Footer({
|
|||||||
<br />
|
<br />
|
||||||
{companyInfo.address.zip} {companyInfo.address.city}
|
{companyInfo.address.zip} {companyInfo.address.city}
|
||||||
<br />
|
<br />
|
||||||
{companyInfo.email}
|
<ContactEmail email={companyInfo.email} linkClassName="transition-colors hover:text-cream" />
|
||||||
</p>
|
</p>
|
||||||
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
|
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
|
||||||
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
|
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
import { getContentValues } from "@/lib/content";
|
import { getContentValues } from "@/lib/content";
|
||||||
|
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||||
/** Bewusst schlicht: nur "[email protected]" ohne Leerzeichen — faengt Verschleierungen wie "info [at] x.de" ab. */
|
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
|
|
||||||
const COMPANY_KEYS = [
|
const COMPANY_KEYS = [
|
||||||
"company.name",
|
"company.name",
|
||||||
@@ -49,11 +47,12 @@ export const getCompanyInfo = cache(async () => {
|
|||||||
claimAlt: v["company.claimAlt"],
|
claimAlt: v["company.claimAlt"],
|
||||||
description: v["company.description"],
|
description: v["company.description"],
|
||||||
location: v["company.location"],
|
location: v["company.location"],
|
||||||
// Altbestand kann eine verschleierte Adresse enthalten ("info [at] …"),
|
// Im Admin darf die Adresse verschleiert gepflegt werden ("info [at] …").
|
||||||
// die das Admin inzwischen ablehnt. Im Impressum und in der
|
// Hier wird sie in die echte Adresse umgerechnet — fuer mailto-Links,
|
||||||
// Widerrufsbelehrung muss sie direkt nutzbar sein — dann lieber die
|
// DHL-Etiketten und die geschuetzte Anzeige (components/contact-email.tsx).
|
||||||
// konfigurierte Standardadresse als eine unbrauchbare.
|
// Die Standardadresse greift nur, wenn der Eintrag gar keine Adresse
|
||||||
email: EMAIL_PATTERN.test(v["company.email"].trim()) ? v["company.email"].trim() : siteConfig.email,
|
// ergibt; das Admin laesst solche Eintraege nicht mehr speichern.
|
||||||
|
email: normalizeContactEmail(v["company.email"]) ?? siteConfig.email,
|
||||||
phone: v["company.phone"],
|
phone: v["company.phone"],
|
||||||
address: {
|
address: {
|
||||||
...siteConfig.address,
|
...siteConfig.address,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decodeEmailPayload, displayEmail, encodeEmailPayload, normalizeContactEmail, protectEmail } from "./contact-email";
|
||||||
|
|
||||||
|
describe("normalizeContactEmail", () => {
|
||||||
|
it.each([
|
||||||
|
["[email protected]", "[email protected]"],
|
||||||
|
[" [email protected] ", "[email protected]"],
|
||||||
|
["info [á] wabenhain.com", "[email protected]"],
|
||||||
|
["info [at] wabenhain.com", "[email protected]"],
|
||||||
|
["info(at)wabenhain(dot)com", "[email protected]"],
|
||||||
|
["info {ät} wabenhain [punkt] com", "[email protected]"],
|
||||||
|
["info [AT] wabenhain [DOT] com", "[email protected]"],
|
||||||
|
["hallo<@>wabenhain.com", "[email protected]"],
|
||||||
|
])("turns %j into a usable address", (input, expected) => {
|
||||||
|
expect(normalizeContactEmail(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "hallo", "info [á] ", "info @ wabenhain", "a@[email protected]", "info [á] wabenhain.com <script>"])("rejects %j", (input) => {
|
||||||
|
expect(normalizeContactEmail(input)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("displayEmail", () => {
|
||||||
|
it("shows a readable, not directly harvestable form", () => {
|
||||||
|
expect(displayEmail("[email protected]")).toBe("info [at] wabenhain.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email payload", () => {
|
||||||
|
it("round-trips and never contains the address in clear text", () => {
|
||||||
|
const payload = encodeEmailPayload("[email protected]", new Uint8Array([7, 42, 199, 3]));
|
||||||
|
expect(payload).not.toContain("@");
|
||||||
|
expect(payload).not.toContain("wabenhain");
|
||||||
|
expect(payload).not.toContain("info");
|
||||||
|
expect(decodeEmailPayload(payload)).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports non-ASCII addresses", () => {
|
||||||
|
const payload = encodeEmailPayload("jü[email protected]", new Uint8Array([1, 2, 3]));
|
||||||
|
expect(decodeEmailPayload(payload)).toBe("jü[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for damaged payloads", () => {
|
||||||
|
expect(decodeEmailPayload("kaputt")).toBeNull();
|
||||||
|
expect(decodeEmailPayload("0102.zz")).toBeNull();
|
||||||
|
expect(decodeEmailPayload("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fresh key per call", () => {
|
||||||
|
const a = protectEmail("[email protected]");
|
||||||
|
const b = protectEmail("[email protected]");
|
||||||
|
expect(a.payload).not.toBe(b.payload);
|
||||||
|
expect(decodeEmailPayload(a.payload)).toBe("[email protected]");
|
||||||
|
expect(a.fallback).toBe("info [at] wabenhain.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Kontakt-E-Mail: im Admin frei pflegbar (auch in verschleierter Form wie
|
||||||
|
* "info [at] wabenhain.com") und auf der Website vor Adress-Sammlern
|
||||||
|
* geschuetzt.
|
||||||
|
*
|
||||||
|
* Schutzprinzip: Im ausgelieferten HTML steht die Adresse nie im Klartext —
|
||||||
|
* nur die lesbare Ersatzform ("info [at] wabenhain.com") und eine
|
||||||
|
* XOR-verschluesselte Zeichenkette mit zufaelligem Schluessel je Aufruf. Erst
|
||||||
|
* der Browser setzt daraus den mailto-Link zusammen
|
||||||
|
* (components/protected-email.tsx). Einfache Crawler, die nur HTML lesen,
|
||||||
|
* finden damit keine Adresse; wer JavaScript ausfuehrt, kann sie — wie jeder
|
||||||
|
* Besucher — trotzdem lesen. Vollstaendigen Schutz gibt es fuer eine
|
||||||
|
* oeffentlich angezeigte Adresse nicht.
|
||||||
|
*
|
||||||
|
* Bewusst ohne node:crypto — das Modul wird auch im Browser (Entschluesseln)
|
||||||
|
* geladen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AT_TOKEN = /\s*[[({<]\s*(?:at|á|ä|ät|@)\s*[\])}>]\s*/gi;
|
||||||
|
const DOT_TOKEN = /\s*[[({<]\s*(?:dot|punkt|\.)\s*[\])}>]\s*/gi;
|
||||||
|
const EMAIL = /^[^\s@<>"'()[\]{}]+@[^\s@<>"'()[\]{}]+\.[^\s@<>"'()[\]{}]+$/;
|
||||||
|
const KEY_BYTES = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus dem Admin-Eintrag eine verwendbare Adresse. Versteht die
|
||||||
|
* ueblichen Verschleierungen ([at], (at), {ät}, [á], <@>, [dot], [punkt]).
|
||||||
|
* null, wenn danach keine gueltige Adresse uebrig bleibt.
|
||||||
|
*/
|
||||||
|
export function normalizeContactEmail(raw: string): string | null {
|
||||||
|
const email = raw.trim().replace(AT_TOKEN, "@").replace(DOT_TOKEN, ".");
|
||||||
|
return EMAIL.test(email) ? email : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lesbare Ersatzform fuer die Anzeige ohne JavaScript: "info [at] wabenhain.com". */
|
||||||
|
export function displayEmail(email: string): string {
|
||||||
|
return email.replace("@", " [at] ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromHex(hex: string): Uint8Array | null {
|
||||||
|
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) return null;
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xor(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||||
|
return data.map((byte, i) => byte ^ key[i % key.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeEmailPayload(email: string, key: Uint8Array): string {
|
||||||
|
return `${toHex(key)}.${toHex(xor(new TextEncoder().encode(email), key))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeEmailPayload(payload: string): string | null {
|
||||||
|
const [keyHex, dataHex] = payload.split(".");
|
||||||
|
const key = fromHex(keyHex ?? "");
|
||||||
|
const data = fromHex(dataHex ?? "");
|
||||||
|
if (!key || !data) return null;
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true }).decode(xor(data, key));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serverseitig: Ersatzanzeige + verschluesselte Adresse fuer components/protected-email.tsx. */
|
||||||
|
export function protectEmail(email: string): { payload: string; fallback: string } {
|
||||||
|
const key = globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
|
||||||
|
return { payload: encodeEmailPayload(email, key), fallback: displayEmail(email) };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user