optical changes

This commit is contained in:
Harlekin committed 2026-10-25 19:35:32 +01:00
1 parent c8df66d3da
commit 5abad7c475
37 files changed
+108 -735

No files matched your search

+3 -10
View File
@@ -4,13 +4,6 @@ SITE_URL=https://lux.harlelux.de
# Docker-Netzwerk des Reverse Proxy (Nginx Proxy Manager: meist nginx_default) # Docker-Netzwerk des Reverse Proxy (Nginx Proxy Manager: meist nginx_default)
PROXY_NETWORK=nginx_default PROXY_NETWORK=nginx_default
# SMTP für das Kontaktformular. Ohne SMTP_HOST wird statt des Formulars nur die # Port im Container. Kollidiert nicht mit anderen Containern, die auch 3000 nutzen,
# E-Mail-Adresse aus content/site.yml angezeigt. # weil kein Port auf dem Host freigegeben wird. Nur ändern, wenn es wirklich nötig ist.
SMTP_HOST= # PORT=3000
SMTP_PORT=587
# true bei Port 465, sonst false (STARTTLS)
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM="Website <website@harlelux.de>"
MAIL_TO=
+1 -1
View File
@@ -22,6 +22,6 @@ RUN mkdir cache && chown node:node cache
USER node USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1 CMD wget -qO- "http://127.0.0.1:${PORT}/healthz" > /dev/null || exit 1
CMD ["node", "src/server.js"] CMD ["node", "src/server.js"]
+3 -3
View File
@@ -12,7 +12,7 @@ npm run dev # http://localhost:3000, baut CSS/JS bei Änderungen neu
npm test npm test
``` ```
Im Dev-Modus sind Einträge mit `draft: true` sichtbar und bekommen ein Entwurf-Badge. Ohne SMTP-Konfiguration landen Mails aus dem Kontaktformular in der Konsole. Im Dev-Modus sind Einträge mit `draft: true` sichtbar und bekommen ein Entwurf-Badge.
## Inhalte ## Inhalte
@@ -22,7 +22,7 @@ content/
home.yml Texte und Kacheln der Startseite home.yml Texte und Kacheln der Startseite
journey.yml Stationen für den Werdegang journey.yml Stationen für den Werdegang
homelab.yml Hardware und Dienste homelab.yml Hardware und Dienste
pages/ Werdegang, Homelab, Datenschutz als <name>.de.md / <name>.en.md pages/ Werdegang und Homelab als <name>.de.md / <name>.en.md
projects/<slug>/ ein Ordner pro Projekt projects/<slug>/ ein Ordner pro Projekt
``` ```
@@ -65,7 +65,7 @@ Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
```sh ```sh
git clone https://git.harlelux.de/Harlekin/Personal-Website.git /opt/stacks/personal-website git clone https://git.harlelux.de/Harlekin/Personal-Website.git /opt/stacks/personal-website
cd /opt/stacks/personal-website cd /opt/stacks/personal-website
cp .env.example .env # SMTP usw. eintragen cp .env.example .env # nur nötig, wenn etwas vom Standard abweicht
docker compose up -d --build docker compose up -d --build
``` ```
-72
View File
@@ -441,78 +441,6 @@
max-width: 52rem; max-width: 52rem;
} }
/* Forms */
.field {
display: grid;
gap: 0.45rem;
}
.field__label {
font-size: 0.92rem;
font-weight: 500;
color: var(--muted);
}
.field__input {
width: 100%;
min-height: 3.1rem;
padding: 0.8rem 1rem;
border: 1px solid var(--line-strong);
border-radius: 0.8rem;
background: var(--surface);
font-size: max(16px, 1rem);
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: rgb(243 238 248 / 0.32);
}
&:focus {
outline: none;
border-color: var(--pink-text);
box-shadow: 0 0 0 4px rgb(255 118 164 / 0.16);
}
}
.field__input--area {
min-height: 11rem;
resize: vertical;
}
.field--invalid .field__input {
border-color: var(--error);
}
.field__error {
font-size: 0.875rem;
color: var(--error);
}
.field--trap {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.form-status {
padding: 1rem 1.25rem;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: var(--surface);
}
.form-status--ok {
border-color: rgb(143 152 255 / 0.5);
background: rgb(47 60 190 / 0.18);
}
.form-status--error {
border-color: rgb(255 154 169 / 0.5);
background: rgb(255 107 129 / 0.1);
}
/* Video */ /* Video */
.video { .video {
position: relative; position: relative;
+1 -19
View File
@@ -406,7 +406,7 @@
color: var(--pink-text); color: var(--pink-text);
} }
/* long German compounds like "Datenschutzerklärung" don't fit on phones otherwise */ /* long German compounds don't fit on phones otherwise */
.page-head__title, .page-head__title,
.project-head__title { .project-head__title {
hyphens: auto; hyphens: auto;
@@ -480,12 +480,6 @@
color: var(--text); color: var(--text);
} }
.site-footer__note {
max-width: 36ch;
margin-top: 0.75rem;
color: var(--muted);
}
.site-footer__heading { .site-footer__heading {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -508,16 +502,4 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--faint); color: var(--faint);
} }
.site-footer .site-footer__legal {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.5rem;
}
.to-top {
display: inline-flex !important;
align-items: center;
gap: 0.35rem;
}
} }
+40 -12
View File
@@ -484,26 +484,54 @@
} }
} }
.contact-form { .contact__mail {
position: relative;
display: grid; display: grid;
gap: 1.25rem; align-content: space-between;
gap: 1.5rem;
min-height: 15rem;
padding: clamp(1.5rem, 4vw, 3rem);
border: 1px solid var(--line);
border-radius: clamp(1.25rem, 3vw, 2rem);
background:
radial-gradient(70% 90% at 100% 0%, rgb(192 17 215 / 0.3), transparent 60%),
radial-gradient(60% 90% at 0% 100%, rgb(47 60 190 / 0.4), transparent 60%),
var(--surface);
text-decoration: none;
& .icon {
transition: translate 0.3s var(--ease-out);
} }
.contact-form__footer { &:hover .icon {
display: flex; translate: 4px 0;
flex-wrap: wrap; }
align-items: center;
gap: 1rem 1.5rem;
margin-top: 0.5rem;
} }
.contact-form__note { .contact__mail-label {
max-width: 42ch; font-family: var(--font-mono);
font-size: 0.85rem; font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
.contact__mail-address {
font-family: var(--font-display);
font-size: clamp(1.5rem, 0.9rem + 3vw, 3rem);
font-weight: 650;
letter-spacing: -0.03em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.contact__mail-action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: var(--pink-text);
}
.contact__aside { .contact__aside {
padding: 1.5rem; padding: 1.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
-66
View File
@@ -1,66 +0,0 @@
export function initContactForm(form) {
const status = form.querySelector('[data-form-status]');
const submit = form.querySelector('[type="submit"]');
const submitLabel = submit.innerHTML;
function setFieldError(name, message) {
const input = form.elements[name];
const error = form.querySelector(`[data-error-for="${name}"]`);
if (!input || !error) return;
input.closest('.field').classList.toggle('field--invalid', Boolean(message));
error.textContent = message || '';
error.hidden = !message;
if (message) {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
function showStatus(message, kind) {
status.textContent = message;
status.className = `form-status form-status--${kind}`;
status.hidden = false;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
form.querySelector('[role="alert"]')?.remove();
for (const name of ['name', 'email', 'message']) setFieldError(name, null);
status.hidden = true;
submit.disabled = true;
submit.textContent = form.dataset.sending;
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams(new FormData(form)),
});
const data = await response.json();
if (data.ok) {
const done = document.createElement('p');
done.className = 'form-status form-status--ok';
done.setAttribute('role', 'status');
done.tabIndex = -1;
done.textContent = data.message;
form.replaceWith(done);
done.focus();
return;
}
for (const [name, message] of Object.entries(data.errors ?? {})) setFieldError(name, message);
showStatus(data.message, 'error');
form.querySelector('[aria-invalid="true"]')?.focus();
} catch {
showStatus(form.dataset.failed, 'error');
} finally {
submit.disabled = false;
submit.innerHTML = submitLabel;
}
});
}
-4
View File
@@ -1,4 +1,3 @@
import { initContactForm } from './contact.js';
import { initFilter } from './filter.js'; import { initFilter } from './filter.js';
import { initFluid } from './fluid.js'; import { initFluid } from './fluid.js';
import { initLightbox } from './lightbox.js'; import { initLightbox } from './lightbox.js';
@@ -22,6 +21,3 @@ const filter = document.querySelector('[data-filter]');
if (filter) initFilter(filter); if (filter) initFilter(filter);
for (const gallery of document.querySelectorAll('[data-gallery]')) initLightbox(gallery); for (const gallery of document.querySelectorAll('[data-gallery]')) initLightbox(gallery);
const form = document.querySelector('[data-contact-form]');
if (form) initContactForm(form);
+14 -14
View File
@@ -14,15 +14,15 @@ areas:
de: RP-Server-Administration de: RP-Server-Administration
en: Roleplay server admin en: Roleplay server admin
text: text:
de: Ich helfe dabei, einen Roleplay-Server zu betreiben. Dazu gehören Updates, Fehlersuche und Absprachen mit dem restlichen Team, wenn etwas nicht so läuft wie geplant. de: Ich helfe im Team bei der Administration eines Roleplay-Servers.
en: I help run a roleplay server. That means updates, troubleshooting and working things out with the rest of the team when something doesn't behave. en: I'm part of the team that administers a roleplay server.
link: portfolio?tag=server link: portfolio?tag=server
- visual: homelab - visual: homelab
title: Homelab title: Homelab
text: text:
de: Ein paar Rechner, Docker und ein Reverse Proxy. Hier probiere ich neue Dienste aus und mache meine Fehler dort, wo sie niemandem schaden. de: Zu Hause betreibe ich einen Server mit Docker, auf dem ich eigene Dienste hoste und neue ausprobiere.
en: A few machines, Docker and a reverse proxy. This is where I try out new services and make my mistakes where they don't hurt anyone. en: At home I run a Docker server where I host my own services and try out new ones.
link: homelab link: homelab
- visual: scripts - visual: scripts
@@ -30,8 +30,8 @@ areas:
de: Skripte & Tools de: Skripte & Tools
en: Scripts & tools en: Scripts & tools
text: text:
de: Kleine Helfer für wiederkehrende Aufgaben. Meistens unspektakulär, aber sie nehmen mir jede Woche ein bisschen Arbeit ab. de: Kleine Skripte für Aufgaben, die regelmäßig anfallen.
en: Small helpers for recurring chores. Mostly unspectacular, but they take a bit of work off my plate every week. en: Small scripts for tasks that come up regularly.
link: portfolio?tag=scripts link: portfolio?tag=scripts
- visual: study - visual: study
@@ -39,8 +39,8 @@ areas:
de: Informatik-Studium de: Informatik-Studium
en: Computer science en: Computer science
text: text:
de: Theorie, Mathe und viel Programmierung. Einiges davon begegnet mir im Homelab wieder, nur mit echten Fehlermeldungen. de: Ich studiere Informatik.
en: Theory, maths and a lot of programming. Some of it shows up again in my homelab, just with real error messages. en: I study computer science.
link: journey link: journey
- visual: video - visual: video
@@ -48,14 +48,14 @@ areas:
de: Videoschnitt de: Videoschnitt
en: Video editing en: Video editing
text: text:
de: Nebenbei versuche ich mich am Videoschnitt. Ich lerne noch, aber an Timing und Ton zu feilen, bis ein Schnitt sitzt, macht mir Spaß. de: Nebenbei schneide ich Videos.
en: On the side I'm learning video editing. I'm still at it, but tweaking timing and sound until a cut feels right is fun. en: I also edit videos on the side.
link: portfolio?tag=video link: portfolio?tag=video
contact: contact:
title: title:
de: Suchst du jemanden für die IT? de: Ich suche eine Stelle in der IT
en: Looking for someone in IT? en: I'm looking for a job in IT
text: text:
de: Ich suche eine Stelle in der IT, am liebsten in der Administration, im Support oder dort, wo sich etwas automatisieren lässt. Schreib mir, wenn das zu euch passt. de: Bei Fragen oder einem passenden Angebot schreib mir gern.
en: I'm looking for a job in IT, ideally in administration, support or anywhere things can be automated. Get in touch if that fits your team. en: If you have questions or an opening, feel free to get in touch.
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: Homelab title: Homelab
lead: Was bei mir zu Hause läuft und wofür. Das meiste davon bräuchte ich nicht, aber ich lerne dabei mehr als aus jedem Tutorial. lead: Was bei mir zu Hause läuft und wofür.
--- ---
## Was ich dabei gelernt habe ## Was ich dabei gelernt habe
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: Homelab title: Homelab
lead: What runs at my place and what it's for. I wouldn't strictly need most of it, but I learn more from it than from any tutorial. lead: What runs at my place and what it's for.
--- ---
## What I learned along the way ## What I learned along the way
+1 -6
View File
@@ -1,10 +1,5 @@
--- ---
title: Werdegang title: Werdegang
lead: Wer ich bin, was ich bisher gemacht habe und womit ich mich auskenne.
--- ---
Ich bin Lux und studiere Informatik. Neben dem Studium helfe ich bei der Administration eines Roleplay-Servers und betreibe zu Hause ein Homelab, in dem ich neue Dienste ausprobiere, bevor ich ihnen vertraue. Ich bin Lux und studiere Informatik. Nebenbei helfe ich bei der Administration eines Roleplay-Servers und betreibe zu Hause ein Homelab. Ab und zu schneide ich Videos.
Am meisten Spaß macht mir Arbeit, bei der am Ende etwas zuverlässig läuft. Ein Dienst läuft oder er läuft nicht, und wenn nicht, gibt es einen Grund, den man finden kann.
Ab und zu schneide ich Videos. Das ist eine andere Art von Basteln, bei der es ebenfalls um Details geht, die am Ende niemandem auffallen sollen.
+1 -6
View File
@@ -1,10 +1,5 @@
--- ---
title: Journey title: Journey
lead: Who I am, what I've done so far and what I know my way around.
--- ---
I'm Lux and I study computer science. Alongside my degree I help administer a roleplay server, and at home I run a homelab where I try out new services before I trust them. I'm Lux and I study computer science. Alongside that I help administer a roleplay server and run a homelab at home. Every now and then I edit videos.
I enjoy work most when something runs reliably at the end of it. A service either works or it doesn't, and if it doesn't, there's a reason you can track down.
Every now and then I edit videos. It's a different kind of tinkering, but it's also about details nobody should notice in the end.
-37
View File
@@ -1,37 +0,0 @@
---
title: Datenschutzerklärung
lead: Wer hier welche Daten verarbeitet und warum. Kurz gesagt so wenig wie möglich.
---
## Verantwortlich
Vorname Nachname<br>
Straße Hausnummer<br>
PLZ Ort<br>
E-Mail: name@example.org
## Beim Aufruf der Seite
Wenn du diese Seite aufrufst, verarbeitet der Server technisch notwendige Daten: deine IP-Adresse, Datum und Uhrzeit, die aufgerufene Adresse, den Browser-Typ (User-Agent) und den HTTP-Statuscode. Das ist nötig, um die Seite auszuliefern und den Betrieb abzusichern. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Die Protokolle werden nach [Anzahl] Tagen gelöscht.
## Cookies, Tracking und Drittanbieter
Diese Seite setzt keine Cookies und verwendet keine Tracking- oder Analyse-Werkzeuge. Schriftarten, Skripte und Bilder kommen von diesem Server, nicht von Drittanbietern. Einzige Ausnahme sind YouTube-Videos, siehe unten.
## Kontaktformular
Wenn du das Kontaktformular nutzt, werden Name, E-Mail-Adresse und Nachricht per E-Mail an mich weitergeleitet. Ich nutze die Angaben nur, um deine Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um eine Bewerbung oder eine mögliche Zusammenarbeit geht, ansonsten Art. 6 Abs. 1 lit. f DSGVO. Auf dem Webserver selbst wird die Nachricht nicht gespeichert. Die E-Mail lösche ich, sobald sie nicht mehr gebraucht wird.
Für den Versand nutze ich den E-Mail-Dienst [Anbieter].
Zum Schutz vor Missbrauch zählt der Server, wie viele Nachrichten von einer IP-Adresse kommen. Dieser Zähler liegt nur im Arbeitsspeicher und wird nach einer Stunde verworfen.
## YouTube-Videos
Manche Projektseiten enthalten Videos von YouTube. Sie werden erst geladen, wenn du auf „Video laden“ klickst. Erst dann wird eine Verbindung zu YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) aufgebaut, dabei werden unter anderem deine IP-Adresse und gegebenenfalls Cookies übertragen. Eingebunden wird der erweiterte Datenschutzmodus über youtube-nocookie.com. Rechtsgrundlage ist deine Einwilligung durch den Klick (Art. 6 Abs. 1 lit. a DSGVO). Details stehen in der [Datenschutzerklärung von Google](https://policies.google.com/privacy).
## Deine Rechte
Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem kannst du dich bei einer Datenschutz-Aufsichtsbehörde beschweren, zum Beispiel bei der deines Bundeslandes.
Stand: Oktober 2026
-39
View File
@@ -1,39 +0,0 @@
---
title: Privacy policy
lead: Who processes which data here and why. In short, as little as possible.
---
The German version of this policy is the binding one. This translation is provided for convenience.
## Controller
First name Last name<br>
Street and number<br>
Postcode, city<br>
Email: name@example.org
## When you visit this site
When you open this site, the server processes technically necessary data: your IP address, date and time, the requested address, your browser type (user agent) and the HTTP status code. This is needed to deliver the site and keep it secure. The legal basis is Art. 6(1)(f) GDPR. Logs are deleted after [number] days.
## Cookies, tracking and third parties
This site doesn't set cookies and doesn't use any tracking or analytics tools. Fonts, scripts and images are served from this server, not from third parties. The only exception are YouTube videos, see below.
## Contact form
If you use the contact form, your name, email address and message are forwarded to me by email. I only use them to reply to your request. The legal basis is Art. 6(1)(b) GDPR when it's about a job application or a possible collaboration, otherwise Art. 6(1)(f) GDPR. The message is not stored on the web server itself. I delete the email once it's no longer needed.
Emails are sent through [provider].
To prevent abuse, the server counts how many messages come from an IP address. This counter only lives in memory and is discarded after one hour.
## YouTube videos
Some project pages contain YouTube videos. They are only loaded after you click "Load video". Only then does your browser connect to YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland), which transfers your IP address and possibly cookies, among other things. Videos are embedded in privacy-enhanced mode via youtube-nocookie.com. The legal basis is your consent given by clicking (Art. 6(1)(a) GDPR). See [Google's privacy policy](https://policies.google.com/privacy) for details.
## Your rights
You have the right to access (Art. 15 GDPR), rectification (Art. 16), erasure (Art. 17), restriction of processing (Art. 18), data portability (Art. 20) and to object (Art. 21). You can also lodge a complaint with a data protection supervisory authority.
Last updated: October 2026
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+3 -9
View File
@@ -3,11 +3,11 @@ pronouns:
de: alle Pronomen de: alle Pronomen
en: any pronouns en: any pronouns
description: description:
de: Lux studiert Informatik, hilft bei der Administration eines Roleplay-Servers und betreibt ein Homelab. Portfolio, Werdegang und Kontakt. de: Lux studiert Informatik, hilft bei der Administration eines Roleplay-Servers und betreibt ein Homelab.
en: Lux studies computer science, helps run a roleplay server and keeps a homelab going. Portfolio, background and contact. en: Lux studies computer science, helps run a roleplay server and has a homelab.
# Leer lassen, wenn die Adresse nicht öffentlich sein soll # Leer lassen, wenn die Adresse nicht öffentlich sein soll
email: '' email: harlekin.lux@gmail.com
links: links:
- label: git.harlelux.de - label: git.harlelux.de
@@ -15,12 +15,6 @@ links:
# - label: LinkedIn # - label: LinkedIn
# url: https://www.linkedin.com/in/... # url: https://www.linkedin.com/in/...
source: https://git.harlelux.de/Harlekin/Personal-Website
footer:
de: Läuft als Docker-Container hinter Nginx Proxy Manager. Ohne Cookies, ohne Tracking.
en: Runs as a Docker container behind Nginx Proxy Manager. No cookies, no tracking.
# Foto für den Werdegang, Pfad relativ zu content/ # Foto für den Werdegang, Pfad relativ zu content/
# avatar: images/portrait.jpg # avatar: images/portrait.jpg
+8 -31
View File
@@ -23,7 +23,7 @@
}, },
"portfolio": { "portfolio": {
"title": "Portfolio", "title": "Portfolio",
"lead": "Projekte aus Homelab, Server-Administration, Studium und Videoschnitt. Manche groß, manche nur ein Skript, das mir Arbeit abnimmt.", "lead": "Projekte aus Homelab, Server-Administration, Studium und Videoschnitt.",
"filterLabel": "Nach Thema filtern", "filterLabel": "Nach Thema filtern",
"all": "Alle", "all": "Alle",
"empty": "Zu diesem Thema gibt es noch nichts.", "empty": "Zu diesem Thema gibt es noch nichts.",
@@ -59,46 +59,23 @@
}, },
"contact": { "contact": {
"title": "Kontakt", "title": "Kontakt",
"lead": "Du hast eine Stelle, eine Frage zu einem Projekt oder willst einfach Hallo sagen? Schreib mir, ich melde mich.", "lead": "Schreib mir einfach eine Mail, ich melde mich.",
"name": "Name", "mail": "E-Mail",
"email": "E-Mail", "write": "Mail schreiben",
"message": "Nachricht", "elsewhere": "Woanders"
"send": "Nachricht senden",
"sending": "Wird gesendet …",
"success": "Danke, deine Nachricht ist angekommen. Ich antworte so bald wie möglich.",
"privacyNote": "Deine Angaben nutze ich nur, um dir zu antworten. Details stehen in der",
"privacyLink": "Datenschutzerklärung",
"direct": "Oder direkt",
"mailOnly": "Das Formular ist gerade nicht aktiv. Schreib mir einfach eine Mail:",
"honeypot": "Dieses Feld bitte leer lassen",
"errors": {
"check": "Bitte prüf deine Eingaben.",
"rateLimited": "Zu viele Nachrichten in kurzer Zeit. Versuch es später noch einmal oder schreib mir direkt eine Mail.",
"sendFailed": "Die Nachricht konnte gerade nicht verschickt werden. Versuch es später noch einmal oder schreib mir direkt.",
"unavailable": "Das Kontaktformular ist gerade nicht verfügbar."
},
"fieldErrors": {
"required": "Bitte ausfüllen.",
"invalid": "Das sieht nicht nach einer E-Mail-Adresse aus.",
"tooShort": "Ein bisschen mehr Text bitte (mindestens 10 Zeichen).",
"tooLong": "Das ist leider zu lang."
}
}, },
"footer": { "footer": {
"pages": "Seiten", "pages": "Seiten",
"elsewhere": "Woanders", "elsewhere": "Woanders"
"source": "Quelltext dieser Seite",
"privacy": "Datenschutz",
"top": "Nach oben"
}, },
"error": { "error": {
"404": { "404": {
"title": "Seite nicht gefunden", "title": "Seite nicht gefunden",
"text": "Unter dieser Adresse gibt es nichts. Vielleicht hat sich der Link geändert." "text": "Unter dieser Adresse gibt es nichts."
}, },
"500": { "500": {
"title": "Da ist etwas schiefgelaufen", "title": "Da ist etwas schiefgelaufen",
"text": "Der Fehler liegt bei mir, nicht bei dir. Versuch es gleich noch einmal." "text": "Bitte versuch es gleich noch einmal."
}, },
"home": "Zur Startseite" "home": "Zur Startseite"
}, },
+8 -31
View File
@@ -23,7 +23,7 @@
}, },
"portfolio": { "portfolio": {
"title": "Portfolio", "title": "Portfolio",
"lead": "Projects from my homelab, server administration, university and video editing. Some are big, some are just a script that saves me time.", "lead": "Projects from my homelab, server administration, university and video editing.",
"filterLabel": "Filter by topic", "filterLabel": "Filter by topic",
"all": "All", "all": "All",
"empty": "Nothing here yet.", "empty": "Nothing here yet.",
@@ -59,46 +59,23 @@
}, },
"contact": { "contact": {
"title": "Contact", "title": "Contact",
"lead": "Got a job opening, a question about one of my projects or just want to say hi? Send me a message and I'll get back to you.", "lead": "Just send me an email and I'll get back to you.",
"name": "Name", "mail": "Email",
"email": "Email", "write": "Write an email",
"message": "Message", "elsewhere": "Elsewhere"
"send": "Send message",
"sending": "Sending …",
"success": "Thanks, your message arrived. I'll reply as soon as I can.",
"privacyNote": "I only use your details to reply to you. More in the",
"privacyLink": "privacy policy",
"direct": "Or directly",
"mailOnly": "The form is currently switched off. Just send me an email:",
"honeypot": "Please leave this field empty",
"errors": {
"check": "Please check your input.",
"rateLimited": "Too many messages in a short time. Please try again later or email me directly.",
"sendFailed": "The message couldn't be sent right now. Please try again later or email me directly.",
"unavailable": "The contact form is currently unavailable."
},
"fieldErrors": {
"required": "Please fill this in.",
"invalid": "That doesn't look like an email address.",
"tooShort": "A little more text please (at least 10 characters).",
"tooLong": "That's too long, sorry."
}
}, },
"footer": { "footer": {
"pages": "Pages", "pages": "Pages",
"elsewhere": "Elsewhere", "elsewhere": "Elsewhere"
"source": "Source code of this site",
"privacy": "Privacy",
"top": "Back to top"
}, },
"error": { "error": {
"404": { "404": {
"title": "Page not found", "title": "Page not found",
"text": "There's nothing at this address. Maybe the link changed." "text": "There's nothing at this address."
}, },
"500": { "500": {
"title": "Something went wrong", "title": "Something went wrong",
"text": "That's on me, not you. Please try again in a moment." "text": "Please try again in a moment."
}, },
"home": "Back to the homepage" "home": "Back to the homepage"
}, },
-62
View File
@@ -10,10 +10,8 @@
"dependencies": { "dependencies": {
"compression": "^1.8.2", "compression": "^1.8.2",
"express": "^5.3.0", "express": "^5.3.0",
"express-rate-limit": "^8.7.1",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"nodemailer": "^10.2.1",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"yaml": "^2.9.1" "yaml": "^2.9.1"
@@ -1468,48 +1466,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-rate-limit/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/express-rate-limit/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1731,15 +1687,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.7.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1841,15 +1788,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/nodemailer": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
"integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/nunjucks": { "node_modules/nunjucks": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz",
-2
View File
@@ -15,10 +15,8 @@
"dependencies": { "dependencies": {
"compression": "^1.8.2", "compression": "^1.8.2",
"express": "^5.3.0", "express": "^5.3.0",
"express-rate-limit": "^8.7.1",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"nodemailer": "^10.2.1",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"yaml": "^2.9.1" "yaml": "^2.9.1"
+1 -4
View File
@@ -8,7 +8,6 @@ import { loadConfig, rootDir } from './config.js';
import { createContentStore } from './content.js'; import { createContentStore } from './content.js';
import { formatDate, localize, locales, pickLocale } from './i18n.js'; import { formatDate, localize, locales, pickLocale } from './i18n.js';
import { mediaHandler } from './images.js'; import { mediaHandler } from './images.js';
import { createMailer } from './mail.js';
import { localeRouter, renderError } from './routes/pages.js'; import { localeRouter, renderError } from './routes/pages.js';
import { seoRouter } from './routes/seo.js'; import { seoRouter } from './routes/seo.js';
@@ -57,10 +56,8 @@ export async function createApp(options = {}) {
const config = options.config ?? loadConfig(); const config = options.config ?? loadConfig();
const content = options.content ?? createContentStore(config); const content = options.content ?? createContentStore(config);
if (!content.get()) await content.reload(); if (!content.get()) await content.reload();
const mailer = options.mailer ?? createMailer(config);
const app = express(); const app = express();
app.set('trust proxy', 'loopback, uniquelocal');
setupViews(app, config); setupViews(app, config);
app.use( app.use(
@@ -96,7 +93,7 @@ export async function createApp(options = {}) {
app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false })); app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false }));
app.get('/media/*file', mediaHandler(config)); app.get('/media/*file', mediaHandler(config));
const deps = { config, content, mailer }; const deps = { config, content };
app.use(seoRouter(deps)); app.use(seoRouter(deps));
app.get('/', (req, res) => { app.get('/', (req, res) => {
-11
View File
@@ -7,14 +7,6 @@ const env = process.env;
export function loadConfig(overrides = {}) { export function loadConfig(overrides = {}) {
const production = env.NODE_ENV === 'production'; const production = env.NODE_ENV === 'production';
const smtp = env.SMTP_HOST
? {
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT) || 587,
secure: env.SMTP_SECURE === 'true',
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
}
: null;
return { return {
production, production,
@@ -23,9 +15,6 @@ export function loadConfig(overrides = {}) {
contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'), contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'),
cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'), cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'),
showDrafts: !production, showDrafts: !production,
smtp,
mailFrom: env.MAIL_FROM || '',
mailTo: env.MAIL_TO || '',
...overrides, ...overrides,
}; };
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import YAML from 'yaml';
import { locales, otherLocale } from './i18n.js'; import { locales, otherLocale } from './i18n.js';
import { isImage, isResizable, mediaUrl, resolveMedia, widths } from './images.js'; import { isImage, isResizable, mediaUrl, resolveMedia, widths } from './images.js';
const textPages = ['homelab', 'journey', 'privacy']; const textPages = ['homelab', 'journey'];
export function parseFrontmatter(source) { export function parseFrontmatter(source) {
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source); const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
-1
View File
@@ -12,7 +12,6 @@ export const routes = {
homelab: { de: 'homelab', en: 'homelab' }, homelab: { de: 'homelab', en: 'homelab' },
journey: { de: 'werdegang', en: 'journey' }, journey: { de: 'werdegang', en: 'journey' },
contact: { de: 'kontakt', en: 'contact' }, contact: { de: 'kontakt', en: 'contact' },
privacy: { de: 'datenschutz', en: 'privacy' },
}; };
const strings = Object.fromEntries( const strings = Object.fromEntries(
-32
View File
@@ -1,32 +0,0 @@
import nodemailer from 'nodemailer';
export function createMailer(config) {
if (config.smtp && config.mailTo) {
const transport = nodemailer.createTransport(config.smtp);
return {
enabled: true,
send: (message) => transport.sendMail({ from: config.mailFrom || config.smtp.auth?.user, to: config.mailTo, ...message }),
};
}
if (config.smtp) console.warn('[mail] SMTP_HOST is set but MAIL_TO is empty, contact form disabled');
// Without SMTP in development, print the mail instead of sending it
if (!config.production) {
const transport = nodemailer.createTransport({ jsonTransport: true });
return {
enabled: true,
send: async (message) => {
const info = await transport.sendMail({ from: 'dev@localhost', to: 'dev@localhost', ...message });
console.log('[mail]', JSON.parse(info.message));
},
};
}
return {
enabled: false,
send: async () => {
throw new Error('mail is not configured');
},
};
}
-118
View File
@@ -1,118 +0,0 @@
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { render } from './pages.js';
const maxLength = { name: 100, email: 200, message: 5000 };
const minMessage = 10;
// Humans need a few seconds to fill in three fields, most bots don't bother waiting
const minFillTime = 3000;
export function validate(body) {
const values = {
name: String(body.name ?? '').trim(),
email: String(body.email ?? '').trim(),
message: String(body.message ?? '').trim(),
};
const errors = {};
if (!values.name) errors.name = 'required';
else if (values.name.length > maxLength.name) errors.name = 'tooLong';
if (!values.email) errors.email = 'required';
else if (values.email.length > maxLength.email) errors.email = 'tooLong';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = 'invalid';
if (values.message.length < minMessage) errors.message = 'tooShort';
else if (values.message.length > maxLength.message) errors.message = 'tooLong';
return { values, errors };
}
export function looksLikeBot(body, now = Date.now()) {
if (body.website) return true;
const started = Number(body.ts);
return !started || now - started < minFillTime;
}
function sameOrigin(req, res, next) {
const origin = req.get('origin');
if (!origin) return next();
try {
if (new URL(origin).host === req.get('host')) return next();
} catch {
// malformed Origin header, treat as foreign
}
res.status(403).type('text').send('Forbidden');
}
const wantsJson = (req) => req.accepts(['html', 'json']) === 'json';
export function contactRouter(lang, deps) {
const router = express.Router();
// One limiter for both languages, otherwise /de and /en would each get their own budget
deps.contactLimiter ??= rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
handler: (req, res) => respond(req, res, 429, { error: 'rateLimited' }),
});
function respond(req, res, status, { values = {}, errors = {}, error = null, sent = false }) {
const { t } = res.locals;
res.status(status);
if (wantsJson(req)) {
return res.json({
ok: sent,
message: sent ? t('contact.success') : error ? t(`contact.errors.${error}`) : t('contact.errors.check'),
errors: Object.fromEntries(Object.entries(errors).map(([field, code]) => [field, t(`contact.fieldErrors.${code}`)])),
});
}
render(res, 'pages/contact.njk', 'contact', {
mailEnabled: deps.mailer.enabled,
form: { values, errors, error, sent },
startedAt: Date.now(),
});
}
router.get('/', (req, res) => {
render(res, 'pages/contact.njk', 'contact', {
mailEnabled: deps.mailer.enabled,
form: { values: {}, errors: {} },
startedAt: Date.now(),
});
});
router.post(
'/',
express.urlencoded({ extended: false, limit: '20kb' }),
express.json({ limit: '20kb' }),
sameOrigin,
deps.contactLimiter,
async (req, res) => {
if (!deps.mailer.enabled) return respond(req, res, 503, { error: 'unavailable' });
const body = req.body ?? {};
const { values, errors } = validate(body);
if (Object.keys(errors).length) return respond(req, res, 422, { values, errors });
// Pretend it worked so bots don't learn anything
if (looksLikeBot(body)) return respond(req, res, 200, { sent: true });
const name = values.name.replace(/\s+/g, ' ');
try {
await deps.mailer.send({
replyTo: { name, address: values.email },
subject: `Kontaktformular: ${name}`,
text: `${values.message}\n\n-- \n${name} <${values.email}>\n${deps.config.siteUrl}/${lang}/`,
});
} catch (err) {
console.error(`[mail] sending failed: ${err.message}`);
return respond(req, res, 502, { values, error: 'sendFailed' });
}
respond(req, res, 200, { sent: true });
},
);
return router;
}
+2 -10
View File
@@ -1,6 +1,5 @@
import express from 'express'; import express from 'express';
import { otherLocale, pathFor, routes, translator } from '../i18n.js'; import { otherLocale, pathFor, routes, translator } from '../i18n.js';
import { contactRouter } from './contact.js';
function locals(lang, deps) { function locals(lang, deps) {
const content = deps.content.get(); const content = deps.content.get();
@@ -21,7 +20,6 @@ function locals(lang, deps) {
const [page, query] = String(value).split('?'); const [page, query] = String(value).split('?');
if (!routes[page]) return value; if (!routes[page]) return value;
if (page === 'homelab' && !content.homelab) return null; if (page === 'homelab' && !content.homelab) return null;
if (page === 'privacy' && !content.pages.privacy) return null;
const tag = new URLSearchParams(query).get('tag'); const tag = new URLSearchParams(query).get('tag');
if (tag && !content.tags.some((entry) => entry.name === tag)) return null; if (tag && !content.tags.some((entry) => entry.name === tag)) return null;
return pathFor(lang, page) + (query ? `?${query}` : ''); return pathFor(lang, page) + (query ? `?${query}` : '');
@@ -40,7 +38,7 @@ function navItems(lang, t, content, current) {
.map(([page, label]) => ({ page, label, href: pathFor(lang, page), current: page === current })); .map(([page, label]) => ({ page, label, href: pathFor(lang, page), current: page === current }));
} }
export function render(res, view, page, data = {}) { function render(res, view, page, data = {}) {
const { lang, t, content } = res.locals; const { lang, t, content } = res.locals;
const routed = page in routes; const routed = page in routes;
res.render(view, { res.render(view, {
@@ -111,13 +109,7 @@ export function localeRouter(lang, deps) {
render(res, 'pages/journey.njk', 'journey', { journey, text: pages.journey?.[lang] ?? {} }); render(res, 'pages/journey.njk', 'journey', { journey, text: pages.journey?.[lang] ?? {} });
}); });
router.get(segment('privacy'), (req, res, next) => { router.get(segment('contact'), (req, res) => render(res, 'pages/contact.njk', 'contact'));
const text = res.locals.content.pages.privacy?.[lang];
if (!text) return next();
render(res, 'pages/privacy.njk', 'privacy', { text });
});
router.use(segment('contact'), contactRouter(lang, deps));
return router; return router;
} }
+1 -2
View File
@@ -21,10 +21,9 @@ export function seoRouter({ config, content }) {
}); });
router.get('/sitemap.xml', (req, res) => { router.get('/sitemap.xml', (req, res) => {
const { homelab, projects, pages } = content.get(); const { homelab, projects } = content.get();
const ids = ['home', 'portfolio', 'journey', 'contact']; const ids = ['home', 'portfolio', 'journey', 'contact'];
if (homelab) ids.push('homelab'); if (homelab) ids.push('homelab');
if (pages.privacy) ids.push('privacy');
const entries = [ const entries = [
...ids.map((page) => urlEntry(config.siteUrl, page)), ...ids.map((page) => urlEntry(config.siteUrl, page)),
+11 -52
View File
@@ -8,60 +8,19 @@
{% set headLead = t('contact.lead') %} {% set headLead = t('contact.lead') %}
{% include "partials/page-head.njk" %} {% include "partials/page-head.njk" %}
<div class="container section section--flush contact"> <div class="container contact">
<div class="contact__main" data-reveal>
{% if form.sent %}
<p class="form-status form-status--ok" role="status">{{ t('contact.success') }}</p>
{% elif mailEnabled %}
<form class="contact-form" method="post" action="{{ url('contact') }}" data-contact-form data-sending="{{ t('contact.sending') }}" data-failed="{{ t('contact.errors.sendFailed') }}">
{% if form.error or form.errors | length %}
<p class="form-status form-status--error" role="alert">{{ t('contact.errors.' + (form.error or 'check')) }}</p>
{% endif %}
<p class="form-status" role="status" data-form-status hidden></p>
{% for field in ['name', 'email'] %}
<div class="field{% if form.errors[field] %} field--invalid{% endif %}">
<label class="field__label" for="contact-{{ field }}">{{ t('contact.' + field) }}</label>
<input class="field__input" id="contact-{{ field }}" name="{{ field }}" type="{{ 'email' if field == 'email' else 'text' }}"
autocomplete="{{ 'email' if field == 'email' else 'name' }}" required maxlength="{{ 200 if field == 'email' else 100 }}"
value="{{ form.values[field] }}" {% if form.errors[field] %}aria-invalid="true" aria-describedby="contact-{{ field }}-error"{% endif %}>
<p class="field__error" id="contact-{{ field }}-error" data-error-for="{{ field }}" {% if not form.errors[field] %}hidden{% endif %}>{% if form.errors[field] %}{{ t('contact.fieldErrors.' + form.errors[field]) }}{% endif %}</p>
</div>
{% endfor %}
<div class="field{% if form.errors.message %} field--invalid{% endif %}">
<label class="field__label" for="contact-message">{{ t('contact.message') }}</label>
<textarea class="field__input field__input--area" id="contact-message" name="message" rows="7" required minlength="10" maxlength="5000"
{% if form.errors.message %}aria-invalid="true" aria-describedby="contact-message-error"{% endif %}>{{ form.values.message }}</textarea>
<p class="field__error" id="contact-message-error" data-error-for="message" {% if not form.errors.message %}hidden{% endif %}>{% if form.errors.message %}{{ t('contact.fieldErrors.' + form.errors.message) }}{% endif %}</p>
</div>
<div class="field field--trap" aria-hidden="true">
<label for="contact-website">{{ t('contact.honeypot') }}</label>
<input id="contact-website" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="ts" value="{{ startedAt }}">
<div class="contact-form__footer">
<button class="btn btn--primary" type="submit" data-magnetic>{{ t('contact.send') }} {{ icon('arrow-right') }}</button>
{% if content.pages.privacy %}
<p class="contact-form__note">{{ t('contact.privacyNote') }} <a href="{{ url('privacy') }}">{{ t('contact.privacyLink') }}</a>.</p>
{% endif %}
</div>
</form>
{% else %}
<p class="form-status">{{ t('contact.mailOnly') }}</p>
{% if site.email %}<p><a class="cta__mail" href="mailto:{{ site.email }}">{{ site.email }}</a></p>{% endif %}
{% endif %}
</div>
{% if site.email or site.links.length %}
<aside class="contact__aside" aria-labelledby="direct-title" data-reveal>
<h2 class="contact__aside-title" id="direct-title">{{ t('contact.direct') }}</h2>
<ul class="contact__links" role="list">
{% if site.email %} {% if site.email %}
<li><a class="link-button" href="mailto:{{ site.email }}">{{ icon('mail') }}<span>{{ site.email }}</span></a></li> <a class="contact__mail" href="mailto:{{ site.email }}" data-reveal data-glow>
<span class="contact__mail-label">{{ t('contact.mail') }}</span>
<span class="contact__mail-address">{{ site.email }}</span>
<span class="contact__mail-action">{{ t('contact.write') }} {{ icon('arrow-right') }}</span>
</a>
{% endif %} {% endif %}
{% if site.links.length %}
<aside class="contact__aside" aria-labelledby="elsewhere-title" data-reveal>
<h2 class="contact__aside-title" id="elsewhere-title">{{ t('contact.elsewhere') }}</h2>
<ul class="contact__links" role="list">
{% for link in site.links %} {% for link in site.links %}
<li><a class="link-button" href="{{ link.url }}" rel="me noopener">{{ icon('branch' if 'git' in link.url else 'external') }}<span>{{ link.label | l }}</span></a></li> <li><a class="link-button" href="{{ link.url }}" rel="me noopener">{{ icon('branch' if 'git' in link.url else 'external') }}<span>{{ link.label | l }}</span></a></li>
{% endfor %} {% endfor %}
-14
View File
@@ -1,14 +0,0 @@
{% extends "layout.njk" %}
{% set pageTitle = text.title %}
{% block content %}
{% set headTitle = text.title %}
{% set headLead = text.lead %}
{% include "partials/page-head.njk" %}
<div class="container section section--flush">
<div class="prose" {% if text.fallback %}lang="{{ text.fallback }}"{% endif %}>
{{ text.html | safe }}
</div>
</div>
{% endblock %}
-7
View File
@@ -1,4 +1,3 @@
{% from "partials/macros.njk" import icon %}
<footer class="site-footer"> <footer class="site-footer">
<div class="container"> <div class="container">
<div class="site-footer__grid"> <div class="site-footer__grid">
@@ -7,7 +6,6 @@
<img class="brand__mark" src="/avatar.png" width="36" height="36" alt=""> <img class="brand__mark" src="/avatar.png" width="36" height="36" alt="">
<span class="brand__name">{{ site.name | lower }}</span> <span class="brand__name">{{ site.name | lower }}</span>
</a> </a>
{% if site.footer %}<p class="site-footer__note">{{ site.footer | l }}</p>{% endif %}
</div> </div>
<nav class="site-footer__col" aria-label="{{ t('footer.pages') }}"> <nav class="site-footer__col" aria-label="{{ t('footer.pages') }}">
@@ -32,11 +30,6 @@
<div class="site-footer__bottom"> <div class="site-footer__bottom">
<p>&copy; {{ year }} {{ site.name }}</p> <p>&copy; {{ year }} {{ site.name }}</p>
<ul class="site-footer__legal">
{% if content.pages.privacy %}<li><a href="{{ url('privacy') }}">{{ t('footer.privacy') }}</a></li>{% endif %}
{% if site.source %}<li><a href="{{ site.source }}">{{ t('footer.source') }}</a></li>{% endif %}
<li><a href="#main" class="to-top">{{ t('footer.top') }} {{ icon('arrow-up') }}</a></li>
</ul>
</div> </div>
</div> </div>
</footer> </footer>
-1
View File
@@ -2,7 +2,6 @@
{%- set paths = { {%- set paths = {
'arrow-right': 'M5 12h14M13 6l6 6-6 6', 'arrow-right': 'M5 12h14M13 6l6 6-6 6',
'arrow-left': 'M19 12H5M11 6l-6 6 6 6', 'arrow-left': 'M19 12H5M11 6l-6 6 6 6',
'arrow-up': 'M12 19V5M6 11l6-6 6 6',
'external': 'M7 17 17 7M8 7h9v9', 'external': 'M7 17 17 7M8 7h9v9',
'mail': 'M3.5 6.5h17v11h-17zM4 7l8 6.5L20 7', 'mail': 'M3.5 6.5h17v11h-17zM4 7l8 6.5L20 7',
'branch': 'M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9', 'branch': 'M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
+7 -54
View File
@@ -11,7 +11,6 @@ import { createFixture } from './fixtures.js';
let dir; let dir;
let server; let server;
let base; let base;
const sent = [];
before(async () => { before(async () => {
dir = await createFixture(); dir = await createFixture();
@@ -23,8 +22,7 @@ before(async () => {
siteUrl: 'https://example.org', siteUrl: 'https://example.org',
}); });
const content = createContentStore(config); const content = createContentStore(config);
const mailer = { enabled: true, send: async (message) => sent.push(message) }; const app = await createApp({ config, content });
const app = await createApp({ config, content, mailer });
server = app.listen(0); server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve)); await new Promise((resolve) => server.once('listening', resolve));
base = `http://127.0.0.1:${server.address().port}`; base = `http://127.0.0.1:${server.address().port}`;
@@ -50,8 +48,6 @@ test('pages render in both languages', async () => {
'/en/journey', '/en/journey',
'/de/kontakt', '/de/kontakt',
'/en/contact', '/en/contact',
'/de/datenschutz',
'/en/privacy',
]) { ]) {
const res = await get(url); const res = await get(url);
assert.equal(res.status, 200, url); assert.equal(res.status, 200, url);
@@ -109,53 +105,10 @@ test('sitemap and robots', async () => {
assert.match(await (await get('/robots.txt')).text(), /Sitemap: https:\/\/example\.org\/sitemap\.xml/); assert.match(await (await get('/robots.txt')).text(), /Sitemap: https:\/\/example\.org\/sitemap\.xml/);
}); });
const form = (fields) => test('contact page links to the email address and has no form', async () => {
new URLSearchParams({ name: 'Ada', email: 'ada@example.org', message: 'Hallo, ich habe eine Frage.', ts: String(Date.now() - 10_000), ...fields }); const html = await (await get('/de/kontakt')).text();
assert.match(html, /href="mailto:test@example\.org"/);
const post = (body, headers = {}) => assert.doesNotMatch(html, /<form/);
get('/de/kontakt', { method: 'POST', body, headers: { Accept: 'application/json', ...headers } }); const res = await get('/de/kontakt', { method: 'POST' });
assert.equal(res.status, 404);
test('contact form', async (t) => {
await t.test('sends valid messages', async () => {
const res = await post(form());
assert.equal(res.status, 200);
assert.equal((await res.json()).ok, true);
assert.equal(sent.length, 1);
assert.equal(sent[0].replyTo.address, 'ada@example.org');
});
await t.test('rejects invalid input with field errors', async () => {
const res = await post(form({ email: 'nope', message: 'kurz' }));
assert.equal(res.status, 422);
const data = await res.json();
assert.ok(data.errors.email);
assert.ok(data.errors.message);
});
await t.test('quietly drops bots', async () => {
let res = await post(form({ website: 'http://spam' }));
assert.equal((await res.json()).ok, true);
res = await post(form({ ts: String(Date.now()) }));
assert.equal((await res.json()).ok, true);
assert.equal(sent.length, 1);
});
await t.test('blocks foreign origins', async () => {
const res = await post(form(), { Origin: 'https://evil.example' });
assert.equal(res.status, 403);
});
await t.test('rate limits', async () => {
// 4 requests above counted, plus this one = 5, the next one is too many
assert.equal((await post(form())).status, 200);
const res = await post(form());
assert.equal(res.status, 429);
assert.match((await res.json()).message, /Zu viele/);
});
await t.test('works as a plain form post', async () => {
const res = await get('/en/contact', { method: 'POST', body: form() });
assert.equal(res.status, 429);
assert.match(await res.text(), /Too many messages/);
});
}); });
-2
View File
@@ -16,8 +16,6 @@ export async function createFixture() {
await write('journey.yml', 'entries:\n - title: Live\n from: 2024\n - title: Hidden\n from: 2023\n draft: true\n'); await write('journey.yml', 'entries:\n - title: Live\n from: 2024\n - title: Hidden\n from: 2023\n draft: true\n');
await write('homelab.yml', 'draft: true\nservices:\n - group: Web\n items:\n - name: nginx\n'); await write('homelab.yml', 'draft: true\nservices:\n - group: Web\n items:\n - name: nginx\n');
await write('pages/journey.de.md', '---\ntitle: Werdegang\n---\nÜber mich'); await write('pages/journey.de.md', '---\ntitle: Werdegang\n---\nÜber mich');
await write('pages/privacy.de.md', '---\ntitle: Datenschutz\n---\nText');
await write('pages/privacy.en.md', '---\ntitle: Privacy\n---\nText');
await write('projects/alpha/meta.yml', 'date: 2026-05\ntags: [web]\nfeatured: true\ncover: cover.png\nvideo: https://youtu.be/dQw4w9WgXcQ\n'); await write('projects/alpha/meta.yml', 'date: 2026-05\ntags: [web]\nfeatured: true\ncover: cover.png\nvideo: https://youtu.be/dQw4w9WgXcQ\n');
await write('projects/alpha/de.md', '---\ntitle: Alpha\nsummary: Erstes Projekt\n---\nText mit Bild\n\n![Ein Bild](cover.png)\n'); await write('projects/alpha/de.md', '---\ntitle: Alpha\nsummary: Erstes Projekt\n---\nText mit Bild\n\n![Ein Bild](cover.png)\n');