optical changes
This commit is contained in:
1 parent
c8df66d3da
commit
5abad7c475
37 files changed
+110
-737
No files matched your search
+3
-10
@@ -4,13 +4,6 @@ SITE_URL=https://lux.harlelux.de
|
||||
# Docker-Netzwerk des Reverse Proxy (Nginx Proxy Manager: meist nginx_default)
|
||||
PROXY_NETWORK=nginx_default
|
||||
|
||||
# SMTP für das Kontaktformular. Ohne SMTP_HOST wird statt des Formulars nur die
|
||||
# E-Mail-Adresse aus content/site.yml angezeigt.
|
||||
SMTP_HOST=
|
||||
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=
|
||||
# Port im Container. Kollidiert nicht mit anderen Containern, die auch 3000 nutzen,
|
||||
# weil kein Port auf dem Host freigegeben wird. Nur ändern, wenn es wirklich nötig ist.
|
||||
# PORT=3000
|
||||
+1
-1
@@ -22,6 +22,6 @@ RUN mkdir cache && chown node:node cache
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
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"]
|
||||
@@ -12,7 +12,7 @@ npm run dev # http://localhost:3000, baut CSS/JS bei Änderungen neu
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ content/
|
||||
home.yml Texte und Kacheln der Startseite
|
||||
journey.yml Stationen für den Werdegang
|
||||
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
|
||||
```
|
||||
|
||||
@@ -65,7 +65,7 @@ Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
|
||||
```sh
|
||||
git clone https://git.harlelux.de/Harlekin/Personal-Website.git /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
|
||||
```
|
||||
|
||||
|
||||
@@ -441,78 +441,6 @@
|
||||
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 {
|
||||
position: relative;
|
||||
|
||||
+1
-19
@@ -406,7 +406,7 @@
|
||||
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,
|
||||
.project-head__title {
|
||||
hyphens: auto;
|
||||
@@ -480,12 +480,6 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.site-footer__note {
|
||||
max-width: 36ch;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-footer__heading {
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
@@ -508,16 +502,4 @@
|
||||
font-size: 0.875rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
+42
-14
@@ -484,26 +484,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
position: relative;
|
||||
.contact__mail {
|
||||
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);
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
translate: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-form__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.contact-form__note {
|
||||
max-width: 42ch;
|
||||
font-size: 0.85rem;
|
||||
.contact__mail-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { initContactForm } from './contact.js';
|
||||
import { initFilter } from './filter.js';
|
||||
import { initFluid } from './fluid.js';
|
||||
import { initLightbox } from './lightbox.js';
|
||||
@@ -22,6 +21,3 @@ const filter = document.querySelector('[data-filter]');
|
||||
if (filter) initFilter(filter);
|
||||
|
||||
for (const gallery of document.querySelectorAll('[data-gallery]')) initLightbox(gallery);
|
||||
|
||||
const form = document.querySelector('[data-contact-form]');
|
||||
if (form) initContactForm(form);
|
||||
+14
-14
@@ -14,15 +14,15 @@ areas:
|
||||
de: RP-Server-Administration
|
||||
en: Roleplay server admin
|
||||
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.
|
||||
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.
|
||||
de: Ich helfe im Team bei der Administration eines Roleplay-Servers.
|
||||
en: I'm part of the team that administers a roleplay server.
|
||||
link: portfolio?tag=server
|
||||
|
||||
- visual: homelab
|
||||
title: Homelab
|
||||
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.
|
||||
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.
|
||||
de: Zu Hause betreibe ich einen Server mit Docker, auf dem ich eigene Dienste hoste und neue ausprobiere.
|
||||
en: At home I run a Docker server where I host my own services and try out new ones.
|
||||
link: homelab
|
||||
|
||||
- visual: scripts
|
||||
@@ -30,8 +30,8 @@ areas:
|
||||
de: Skripte & Tools
|
||||
en: Scripts & tools
|
||||
text:
|
||||
de: Kleine Helfer für wiederkehrende Aufgaben. Meistens unspektakulär, aber sie nehmen mir jede Woche ein bisschen Arbeit ab.
|
||||
en: Small helpers for recurring chores. Mostly unspectacular, but they take a bit of work off my plate every week.
|
||||
de: Kleine Skripte für Aufgaben, die regelmäßig anfallen.
|
||||
en: Small scripts for tasks that come up regularly.
|
||||
link: portfolio?tag=scripts
|
||||
|
||||
- visual: study
|
||||
@@ -39,8 +39,8 @@ areas:
|
||||
de: Informatik-Studium
|
||||
en: Computer science
|
||||
text:
|
||||
de: Theorie, Mathe und viel Programmierung. Einiges davon begegnet mir im Homelab wieder, nur mit echten Fehlermeldungen.
|
||||
en: Theory, maths and a lot of programming. Some of it shows up again in my homelab, just with real error messages.
|
||||
de: Ich studiere Informatik.
|
||||
en: I study computer science.
|
||||
link: journey
|
||||
|
||||
- visual: video
|
||||
@@ -48,14 +48,14 @@ areas:
|
||||
de: Videoschnitt
|
||||
en: Video editing
|
||||
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ß.
|
||||
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.
|
||||
de: Nebenbei schneide ich Videos.
|
||||
en: I also edit videos on the side.
|
||||
link: portfolio?tag=video
|
||||
|
||||
contact:
|
||||
title:
|
||||
de: Suchst du jemanden für die IT?
|
||||
en: Looking for someone in IT?
|
||||
de: Ich suche eine Stelle in der IT
|
||||
en: I'm looking for a job in IT
|
||||
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.
|
||||
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.
|
||||
de: Bei Fragen oder einem passenden Angebot schreib mir gern.
|
||||
en: If you have questions or an opening, feel free to get in touch.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
---
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -1,10 +1,5 @@
|
||||
---
|
||||
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 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.
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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
@@ -3,11 +3,11 @@ pronouns:
|
||||
de: alle Pronomen
|
||||
en: any pronouns
|
||||
description:
|
||||
de: Lux studiert Informatik, hilft bei der Administration eines Roleplay-Servers und betreibt ein Homelab. Portfolio, Werdegang und Kontakt.
|
||||
en: Lux studies computer science, helps run a roleplay server and keeps a homelab going. Portfolio, background and contact.
|
||||
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 has a homelab.
|
||||
|
||||
# Leer lassen, wenn die Adresse nicht öffentlich sein soll
|
||||
email: ''
|
||||
email: harlekin.lux@gmail.com
|
||||
|
||||
links:
|
||||
- label: git.harlelux.de
|
||||
@@ -15,12 +15,6 @@ links:
|
||||
# - label: LinkedIn
|
||||
# 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/
|
||||
# avatar: images/portrait.jpg
|
||||
|
||||
|
||||
+8
-31
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"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",
|
||||
"all": "Alle",
|
||||
"empty": "Zu diesem Thema gibt es noch nichts.",
|
||||
@@ -59,46 +59,23 @@
|
||||
},
|
||||
"contact": {
|
||||
"title": "Kontakt",
|
||||
"lead": "Du hast eine Stelle, eine Frage zu einem Projekt oder willst einfach Hallo sagen? Schreib mir, ich melde mich.",
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"message": "Nachricht",
|
||||
"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."
|
||||
}
|
||||
"lead": "Schreib mir einfach eine Mail, ich melde mich.",
|
||||
"mail": "E-Mail",
|
||||
"write": "Mail schreiben",
|
||||
"elsewhere": "Woanders"
|
||||
},
|
||||
"footer": {
|
||||
"pages": "Seiten",
|
||||
"elsewhere": "Woanders",
|
||||
"source": "Quelltext dieser Seite",
|
||||
"privacy": "Datenschutz",
|
||||
"top": "Nach oben"
|
||||
"elsewhere": "Woanders"
|
||||
},
|
||||
"error": {
|
||||
"404": {
|
||||
"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": {
|
||||
"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"
|
||||
},
|
||||
|
||||
+8
-31
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"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",
|
||||
"all": "All",
|
||||
"empty": "Nothing here yet.",
|
||||
@@ -59,46 +59,23 @@
|
||||
},
|
||||
"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.",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"message": "Message",
|
||||
"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."
|
||||
}
|
||||
"lead": "Just send me an email and I'll get back to you.",
|
||||
"mail": "Email",
|
||||
"write": "Write an email",
|
||||
"elsewhere": "Elsewhere"
|
||||
},
|
||||
"footer": {
|
||||
"pages": "Pages",
|
||||
"elsewhere": "Elsewhere",
|
||||
"source": "Source code of this site",
|
||||
"privacy": "Privacy",
|
||||
"top": "Back to top"
|
||||
"elsewhere": "Elsewhere"
|
||||
},
|
||||
"error": {
|
||||
"404": {
|
||||
"title": "Page not found",
|
||||
"text": "There's nothing at this address. Maybe the link changed."
|
||||
"text": "There's nothing at this address."
|
||||
},
|
||||
"500": {
|
||||
"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"
|
||||
},
|
||||
|
||||
Generated
-62
@@ -10,10 +10,8 @@
|
||||
"dependencies": {
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"express-rate-limit": "^8.7.1",
|
||||
"helmet": "^8.3.0",
|
||||
"marked": "^18.1.1",
|
||||
"nodemailer": "^10.2.1",
|
||||
"nunjucks": "^3.2.4",
|
||||
"sharp": "^0.35.5",
|
||||
"yaml": "^2.9.1"
|
||||
@@ -1468,48 +1466,6 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1731,15 +1687,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1841,15 +1788,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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz",
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
"dependencies": {
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"express-rate-limit": "^8.7.1",
|
||||
"helmet": "^8.3.0",
|
||||
"marked": "^18.1.1",
|
||||
"nodemailer": "^10.2.1",
|
||||
"nunjucks": "^3.2.4",
|
||||
"sharp": "^0.35.5",
|
||||
"yaml": "^2.9.1"
|
||||
|
||||
+1
-4
@@ -8,7 +8,6 @@ import { loadConfig, rootDir } from './config.js';
|
||||
import { createContentStore } from './content.js';
|
||||
import { formatDate, localize, locales, pickLocale } from './i18n.js';
|
||||
import { mediaHandler } from './images.js';
|
||||
import { createMailer } from './mail.js';
|
||||
import { localeRouter, renderError } from './routes/pages.js';
|
||||
import { seoRouter } from './routes/seo.js';
|
||||
|
||||
@@ -57,10 +56,8 @@ export async function createApp(options = {}) {
|
||||
const config = options.config ?? loadConfig();
|
||||
const content = options.content ?? createContentStore(config);
|
||||
if (!content.get()) await content.reload();
|
||||
const mailer = options.mailer ?? createMailer(config);
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 'loopback, uniquelocal');
|
||||
setupViews(app, config);
|
||||
|
||||
app.use(
|
||||
@@ -96,7 +93,7 @@ export async function createApp(options = {}) {
|
||||
app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false }));
|
||||
app.get('/media/*file', mediaHandler(config));
|
||||
|
||||
const deps = { config, content, mailer };
|
||||
const deps = { config, content };
|
||||
app.use(seoRouter(deps));
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
@@ -7,14 +7,6 @@ const env = process.env;
|
||||
|
||||
export function loadConfig(overrides = {}) {
|
||||
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 {
|
||||
production,
|
||||
@@ -23,9 +15,6 @@ export function loadConfig(overrides = {}) {
|
||||
contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'),
|
||||
cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'),
|
||||
showDrafts: !production,
|
||||
smtp,
|
||||
mailFrom: env.MAIL_FROM || '',
|
||||
mailTo: env.MAIL_TO || '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import YAML from 'yaml';
|
||||
import { locales, otherLocale } from './i18n.js';
|
||||
import { isImage, isResizable, mediaUrl, resolveMedia, widths } from './images.js';
|
||||
|
||||
const textPages = ['homelab', 'journey', 'privacy'];
|
||||
const textPages = ['homelab', 'journey'];
|
||||
|
||||
export function parseFrontmatter(source) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
|
||||
|
||||
@@ -12,7 +12,6 @@ export const routes = {
|
||||
homelab: { de: 'homelab', en: 'homelab' },
|
||||
journey: { de: 'werdegang', en: 'journey' },
|
||||
contact: { de: 'kontakt', en: 'contact' },
|
||||
privacy: { de: 'datenschutz', en: 'privacy' },
|
||||
};
|
||||
|
||||
const strings = Object.fromEntries(
|
||||
|
||||
-32
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { otherLocale, pathFor, routes, translator } from '../i18n.js';
|
||||
import { contactRouter } from './contact.js';
|
||||
|
||||
function locals(lang, deps) {
|
||||
const content = deps.content.get();
|
||||
@@ -21,7 +20,6 @@ function locals(lang, deps) {
|
||||
const [page, query] = String(value).split('?');
|
||||
if (!routes[page]) return value;
|
||||
if (page === 'homelab' && !content.homelab) return null;
|
||||
if (page === 'privacy' && !content.pages.privacy) return null;
|
||||
const tag = new URLSearchParams(query).get('tag');
|
||||
if (tag && !content.tags.some((entry) => entry.name === tag)) return null;
|
||||
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 }));
|
||||
}
|
||||
|
||||
export function render(res, view, page, data = {}) {
|
||||
function render(res, view, page, data = {}) {
|
||||
const { lang, t, content } = res.locals;
|
||||
const routed = page in routes;
|
||||
res.render(view, {
|
||||
@@ -111,13 +109,7 @@ export function localeRouter(lang, deps) {
|
||||
render(res, 'pages/journey.njk', 'journey', { journey, text: pages.journey?.[lang] ?? {} });
|
||||
});
|
||||
|
||||
router.get(segment('privacy'), (req, res, next) => {
|
||||
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));
|
||||
router.get(segment('contact'), (req, res) => render(res, 'pages/contact.njk', 'contact'));
|
||||
|
||||
return router;
|
||||
}
|
||||
+1
-2
@@ -21,10 +21,9 @@ export function seoRouter({ config, content }) {
|
||||
});
|
||||
|
||||
router.get('/sitemap.xml', (req, res) => {
|
||||
const { homelab, projects, pages } = content.get();
|
||||
const { homelab, projects } = content.get();
|
||||
const ids = ['home', 'portfolio', 'journey', 'contact'];
|
||||
if (homelab) ids.push('homelab');
|
||||
if (pages.privacy) ids.push('privacy');
|
||||
|
||||
const entries = [
|
||||
...ids.map((page) => urlEntry(config.siteUrl, page)),
|
||||
|
||||
+11
-52
@@ -8,60 +8,19 @@
|
||||
{% set headLead = t('contact.lead') %}
|
||||
{% include "partials/page-head.njk" %}
|
||||
|
||||
<div class="container section section--flush 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>
|
||||
<div class="container contact">
|
||||
{% if site.email %}
|
||||
<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 %}
|
||||
|
||||
{% 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>
|
||||
{% 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">
|
||||
{% if site.email %}
|
||||
<li><a class="link-button" href="mailto:{{ site.email }}">{{ icon('mail') }}<span>{{ site.email }}</span></a></li>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,4 +1,3 @@
|
||||
{% from "partials/macros.njk" import icon %}
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<div class="site-footer__grid">
|
||||
@@ -7,7 +6,6 @@
|
||||
<img class="brand__mark" src="/avatar.png" width="36" height="36" alt="">
|
||||
<span class="brand__name">{{ site.name | lower }}</span>
|
||||
</a>
|
||||
{% if site.footer %}<p class="site-footer__note">{{ site.footer | l }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<nav class="site-footer__col" aria-label="{{ t('footer.pages') }}">
|
||||
@@ -32,11 +30,6 @@
|
||||
|
||||
<div class="site-footer__bottom">
|
||||
<p>© {{ 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>
|
||||
</footer>
|
||||
@@ -2,7 +2,6 @@
|
||||
{%- set paths = {
|
||||
'arrow-right': 'M5 12h14M13 6l6 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',
|
||||
'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',
|
||||
|
||||
+7
-54
@@ -11,7 +11,6 @@ import { createFixture } from './fixtures.js';
|
||||
let dir;
|
||||
let server;
|
||||
let base;
|
||||
const sent = [];
|
||||
|
||||
before(async () => {
|
||||
dir = await createFixture();
|
||||
@@ -23,8 +22,7 @@ before(async () => {
|
||||
siteUrl: 'https://example.org',
|
||||
});
|
||||
const content = createContentStore(config);
|
||||
const mailer = { enabled: true, send: async (message) => sent.push(message) };
|
||||
const app = await createApp({ config, content, mailer });
|
||||
const app = await createApp({ config, content });
|
||||
server = app.listen(0);
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
@@ -50,8 +48,6 @@ test('pages render in both languages', async () => {
|
||||
'/en/journey',
|
||||
'/de/kontakt',
|
||||
'/en/contact',
|
||||
'/de/datenschutz',
|
||||
'/en/privacy',
|
||||
]) {
|
||||
const res = await get(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/);
|
||||
});
|
||||
|
||||
const form = (fields) =>
|
||||
new URLSearchParams({ name: 'Ada', email: 'ada@example.org', message: 'Hallo, ich habe eine Frage.', ts: String(Date.now() - 10_000), ...fields });
|
||||
|
||||
const post = (body, headers = {}) =>
|
||||
get('/de/kontakt', { method: 'POST', body, headers: { Accept: 'application/json', ...headers } });
|
||||
|
||||
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/);
|
||||
});
|
||||
test('contact page links to the email address and has no form', async () => {
|
||||
const html = await (await get('/de/kontakt')).text();
|
||||
assert.match(html, /href="mailto:test@example\.org"/);
|
||||
assert.doesNotMatch(html, /<form/);
|
||||
const res = await get('/de/kontakt', { method: 'POST' });
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
@@ -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('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/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/de.md', '---\ntitle: Alpha\nsummary: Erstes Projekt\n---\nText mit Bild\n\n\n');
|
||||
|
||||
Reference in new issue
Block a user