diff --git a/.dockerignore b/.dockerignore index b06fce8..f2398f0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules dist cache +data test .env *.log diff --git a/.env.example b/.env.example index 3d2a275..36886af 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,9 @@ PROXY_NETWORK=nginx_default # 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 + +# Passwort für die Admin-Seiten. Ohne Passwort bleiben sie aus. +ADMIN_PASSWORD= + +# Port auf dem Server, unter dem die Admin-Seiten im Heimnetz erreichbar sind +# ADMIN_HOST_PORT=3051 diff --git a/.gitignore b/.gitignore index 676d040..8fa707a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ cache/ +data/ .env *.log .DS_Store diff --git a/Dockerfile b/Dockerfile index 0887269..7cf5232 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,10 +17,10 @@ COPY src ./src COPY locales ./locales COPY public ./public COPY content ./content -RUN mkdir cache && chown node:node cache +RUN mkdir cache data && chown node:node cache data USER node -EXPOSE 3000 +EXPOSE 3000 3001 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- "http://127.0.0.1:${PORT}/healthz" > /dev/null || exit 1 diff --git a/README.md b/README.md index 4b72b07..496b329 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lux.harlelux.de -Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown in `content/`, ein CMS gibt es nicht. +Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown auf der Platte, gepflegt werden sie über ein paar Admin-Seiten, die nur im Heimnetz erreichbar sind. ## Entwicklung @@ -8,76 +8,65 @@ Node 24 (siehe `.nvmrc`). ```sh npm install -npm run dev # http://localhost:3000, baut CSS/JS bei Änderungen neu +ADMIN_PASSWORD=test npm run dev # Seite auf :3000, Admin auf :3001/admin npm test ``` -Im Dev-Modus sind Einträge mit `draft: true` sichtbar und bekommen ein Entwurf-Badge. +Im Dev-Modus sind Entwürfe auf der Seite sichtbar und bekommen ein Badge. Ohne `ADMIN_PASSWORD` bleiben die Admin-Seiten aus. ## Inhalte +Es gibt zwei Ordner: + +- `content/` im Repo: Startinhalte und alles, was es im Admin nicht gibt (Texte und Kacheln der Startseite in `home.yml`). +- `data/` auf dem Server, nicht im Repo: alles, was über die Admin-Seiten bearbeitet wird. + +Beim allerersten Start wird der bearbeitbare Teil von `content/` nach `data/` kopiert (Projekte, Werdegang, Profil, Homelab). Danach gilt nur noch `data/`, spätere Änderungen an diesen Dateien im Repo werden ignoriert. + ``` -content/ +data/ site.yml Name, Pronomen, Links, Skills, "Gerade dran" - home.yml Texte und Kacheln der Startseite journey.yml Stationen für den Werdegang homelab.yml Hardware und Dienste pages/ Werdegang und Homelab als .de.md / .en.md - projects// ein Ordner pro Projekt + projects// meta.yml, de.md, en.md und die Bilder ``` -Texte, die in beiden Sprachen gleich sind, stehen einfach als String. Sonst: +Die Dateien lassen sich auch von Hand bearbeiten, die Seite lädt sie bei Änderungen neu. Texte, die in beiden Sprachen gleich sind, stehen als einfacher String, sonst als `{ de, en }`. -```yaml -title: - de: Videoschnitt - en: Video editing +## Admin-Seiten + +Laufen im selben Container auf einem eigenen Port (im Container 3001, auf dem Server `ADMIN_HOST_PORT`, Standard 3051). Über `lux.harlelux.de` gibt es kein `/admin`. Der Port wird von der FRITZ!Box nicht freigegeben, ist also nur im Heimnetz erreichbar: + +``` +http://:3051/admin ``` -### Neues Projekt +Zusätzlich lässt die App dort nur private Adressen durch und verlangt das Passwort aus `ADMIN_PASSWORD`. Nach zehn Fehlversuchen ist der Login eine Viertelstunde gesperrt. -`content/projects/mein-projekt/meta.yml`: +Unter derselben Adresse zeigt `/de/` die ganze Seite mit Entwürfen, als Vorschau. -```yaml -date: 2026-09 # Jahr, Monat oder Tag -tags: [homelab, docker] -featured: true # auf der Startseite zeigen -draft: true # online erst sichtbar, wenn das weg ist -cover: cover.jpg -video: https://youtu.be/... # oder eine Datei aus dem Ordner, z.B. clip.mp4 -gallery: - - screenshot.png - - src: rack.jpg - caption: - de: Das Rack - en: The rack -links: - - label: Repo - url: https://git.harlelux.de/Harlekin/... -``` - -Dazu `de.md` und `en.md` mit `title` und `summary` im Frontmatter und dem Text darunter. Fehlt eine Sprache, wird die andere angezeigt. Bilder liegen im selben Ordner und werden relativ eingebunden (`![Beschreibung](bild.jpg)`), die Seite erzeugt automatisch kleinere WebP-Versionen. +Hochgeladene Bilder werden auf höchstens 2400 px verkleinert und neu gespeichert, dabei fallen EXIF-Daten wie der Aufnahmeort weg. ## Deployment 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 # nur nötig, wenn etwas vom Standard abweicht +cd ~/stacks +git clone https://git.harlelux.de/Harlekin/Personal-Website.git personal_website +cd personal_website +mkdir data # muss dem Container-User (UID 1000) gehören +cp .env.example .env # ADMIN_PASSWORD eintragen docker compose up -d --build ``` Im Nginx Proxy Manager einen Proxy Host für `lux.harlelux.de` auf `personal-website:3000` anlegen, mit Let's-Encrypt-Zertifikat und Force SSL. Der Container hängt im Netzwerk `nginx_default`, ein anderer Name lässt sich über `PROXY_NETWORK` in der `.env` setzen. -Updates: +Code-Updates: `git pull` und `docker compose up -d --build`. Wer statt Git mit `rsync` hochlädt, muss `data` und `.env` ausschließen, sonst sind die Inhalte weg. -- **Nur Inhalte geändert:** `git pull` reicht. `content/` ist in den Container gemountet und wird automatisch neu geladen. -- **Code geändert:** `git pull` und `docker compose up -d --build`. - -Neu laden lässt sich der Inhalt auch von Hand mit `docker kill -s HUP personal-website`. +**`data/` gehört ins Backup.** Dort liegt alles, was über die Admin-Seiten angelegt wurde. ## Lizenz -Der Code steht unter der MIT-Lizenz (siehe `LICENSE`). Texte und Bilder in `content/` sind davon ausgenommen. +Der Code steht unter der MIT-Lizenz (siehe `LICENSE`). Texte und Bilder in `content/` und `data/` sind davon ausgenommen. diff --git a/client/css/admin.css b/client/css/admin.css new file mode 100644 index 0000000..1f2deaa --- /dev/null +++ b/client/css/admin.css @@ -0,0 +1,612 @@ +@layer reset, base, layout, components, pages, motion, utilities; + +@import "@fontsource-variable/bricolage-grotesque/opsz.css"; +@import "@fontsource/ibm-plex-sans/latin-400.css"; +@import "@fontsource/ibm-plex-sans/latin-500.css"; +@import "@fontsource/ibm-plex-sans/latin-600.css"; +@import "@fontsource/ibm-plex-mono/latin-400.css"; + +@import "./tokens.css"; +@import "./base.css"; + +@layer layout { + .admin { + --gutter: clamp(1rem, 3vw, 2rem); + font-size: 1rem; + } + + .bar { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--line); + background: rgb(12 10 18 / 0.85); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + } + + .bar__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; + max-width: 72rem; + margin: 0 auto; + padding: 0.6rem var(--gutter); + } + + .bar__brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 700; + text-decoration: none; + + & img { + border-radius: 50%; + } + } + + .bar__nav { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + + & a { + padding: 0.5rem 0.8rem; + border-radius: 99px; + color: var(--muted); + text-decoration: none; + + &:hover { + color: var(--text); + } + + &[aria-current="page"] { + background: var(--surface-2); + color: var(--text); + } + } + } + + .bar__end { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; + } + + .main { + max-width: 72rem; + margin: 0 auto; + padding: 2rem var(--gutter) 5rem; + } + + .heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + + & h1 { + font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.6rem); + } + } +} + +@layer components { + .link { + padding: 0; + border: 0; + background: none; + color: var(--muted); + font-size: 0.95rem; + text-decoration: none; + cursor: pointer; + + &:hover { + color: var(--text); + } + } + + .button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.75rem; + padding: 0 1.25rem; + border: 1px solid transparent; + border-radius: 99px; + background: var(--text); + color: var(--bg); + font-weight: 500; + text-decoration: none; + transition: background-color 0.2s, border-color 0.2s; + + &:hover { + background: var(--pink-text); + } + } + + .button--ghost { + border-color: var(--line-strong); + background: transparent; + color: var(--text); + + &:hover { + border-color: var(--text); + background: transparent; + } + } + + .button--small { + min-height: 2.25rem; + padding: 0 0.9rem; + font-size: 0.9rem; + } + + .button--danger { + border-color: rgb(255 154 169 / 0.5); + background: transparent; + color: var(--error); + + &:hover { + background: rgb(255 107 129 / 0.12); + } + } + + .notice { + margin-bottom: 1.5rem; + padding: 0.85rem 1.1rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + } + + .notice--ok { + border-color: rgb(143 152 255 / 0.5); + background: rgb(47 60 190 / 0.18); + } + + .notice--error { + border-color: rgb(255 154 169 / 0.5); + background: rgb(255 107 129 / 0.12); + } + + .badge { + padding: 0.15rem 0.55rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + } + + .badge--draft { + border-style: dashed; + border-color: rgb(255 141 180 / 0.6); + color: var(--pink-text); + } + + .empty { + color: var(--muted); + } + + /* Forms */ + .form { + display: grid; + gap: 1.25rem; + } + + .panel { + display: grid; + gap: 1rem; + padding: clamp(1.1rem, 3vw, 1.75rem); + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: var(--surface); + } + + .panel__title { + font-size: 1.35rem; + } + + .panel__subtitle { + margin-top: 0.5rem; + font-size: 1.05rem; + } + + .panel__hint { + margin-top: -0.4rem; + color: var(--muted); + font-size: 0.9rem; + + & code { + padding: 0.1em 0.35em; + border-radius: 0.3rem; + background: rgb(243 238 248 / 0.08); + font-size: 0.85em; + } + } + + .grid { + display: grid; + gap: 1rem; + + @media (min-width: 48em) { + &.grid--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + &.grid--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + } + + .field { + display: grid; + gap: 0.35rem; + align-content: start; + } + + .field__label { + font-size: 0.88rem; + font-weight: 500; + color: var(--muted); + } + + .field__hint { + font-size: 0.8rem; + color: var(--faint); + } + + .input { + width: 100%; + min-height: 2.75rem; + padding: 0.6rem 0.85rem; + border: 1px solid var(--line-strong); + border-radius: 0.65rem; + background: var(--bg-raised); + font-size: max(16px, 0.95rem); + line-height: 1.5; + + &:focus { + outline: none; + border-color: var(--pink-text); + box-shadow: 0 0 0 3px rgb(255 118 164 / 0.18); + } + + &:user-invalid { + border-color: var(--error); + } + } + + textarea.input { + resize: vertical; + } + + .input--mono { + font-family: var(--font-mono); + font-size: max(16px, 0.88rem); + } + + input[type="file"].input { + padding: 0.5rem; + } + + .checks { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + } + + .check { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-height: 2.25rem; + cursor: pointer; + + & input { + width: 1.15rem; + height: 1.15rem; + accent-color: var(--pink); + } + } + + .pair { + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; + border: 0; + min-width: 0; + } + + .pair__fields { + display: grid; + gap: 0.5rem; + + @media (min-width: 48em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .pair__field { + position: relative; + + & .input { + padding-left: 2.6rem; + } + } + + .pair__lang { + position: absolute; + top: 0.75rem; + left: 0.75rem; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--faint); + pointer-events: none; + } + + .langs { + display: grid; + gap: 1.5rem; + + @media (min-width: 64em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .lang { + display: grid; + gap: 1rem; + align-content: start; + } + + .lang__title { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pink-text); + } + + .actions { + position: sticky; + bottom: 0; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, transparent, var(--bg) 35%); + } + + .danger { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--line); + } + + /* Repeated rows */ + .repeat { + display: grid; + gap: 0.75rem; + justify-items: start; + } + + .repeat__list { + display: grid; + gap: 0.75rem; + width: 100%; + + &:empty { + display: none; + } + } + + .row { + display: grid; + gap: 0.9rem; + margin: 0; + padding: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-raised); + min-width: 0; + } + + .row--nested { + background: var(--surface); + } + + .row__tools { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .tool { + min-height: 2.25rem; + padding: 0 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: transparent; + color: var(--muted); + font-size: 0.85rem; + + &:hover { + border-color: var(--line-strong); + color: var(--text); + } + } + + .tool--danger:hover { + border-color: rgb(255 154 169 / 0.5); + color: var(--error); + } + + /* Images */ + .cover { + display: grid; + gap: 1rem; + + @media (min-width: 48em) { + grid-template-columns: 16rem minmax(0, 1fr); + align-items: start; + } + } + + .cover__preview { + display: grid; + place-items: center; + aspect-ratio: 16 / 10; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-raised); + color: var(--faint); + font-size: 0.9rem; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .cover__fields { + display: grid; + gap: 0.75rem; + } + + .gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + .gallery__item { + display: grid; + gap: 0.6rem; + align-content: start; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-raised); + } + + .gallery__img { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 0.5rem; + object-fit: cover; + } + + .gallery__name { + font-size: 0.78rem; + color: var(--muted); + overflow-wrap: anywhere; + } + + /* Project list */ + .projects { + display: grid; + gap: 0.6rem; + margin: 0; + padding: 0; + list-style: none; + } + + .project { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem 1rem; + padding: 0.6rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + text-decoration: none; + transition: border-color 0.2s; + + &:hover { + border-color: var(--line-strong); + } + + @media (min-width: 40em) { + grid-template-columns: 7rem minmax(0, 1fr) auto; + } + } + + .project__thumb { + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 0.5rem; + background: var(--surface-2); + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .project__text { + display: grid; + gap: 0.15rem; + } + + .project__title { + font-weight: 600; + } + + .project__meta { + font-size: 0.85rem; + color: var(--muted); + } + + .project__badges { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + + @media (max-width: 39.99em) { + grid-column: 2; + } + } + + /* Login */ + .login { + display: grid; + justify-items: center; + gap: 1rem; + max-width: 22rem; + margin: 12vh auto 0; + text-align: center; + } + + .login__avatar { + border-radius: 50%; + box-shadow: 0 0 0 1px var(--line-strong), 0 0 40px rgb(255 118 164 / 0.3); + } + + .login__form { + display: grid; + gap: 1rem; + width: 100%; + margin-top: 0.5rem; + text-align: left; + } +} diff --git a/client/js/admin.js b/client/js/admin.js new file mode 100644 index 0000000..122bd08 --- /dev/null +++ b/client/js/admin.js @@ -0,0 +1,65 @@ +// Repeated rows (links, timeline entries, services ...) can be added, removed and moved. +// Their fields use "#" as index placeholder in data-name, the real names are set on submit. + +document.addEventListener('click', (event) => { + const button = event.target.closest('[data-repeat-add], [data-repeat-remove], [data-repeat-up], [data-repeat-down]'); + if (!button) return; + + if (button.hasAttribute('data-repeat-add')) { + const repeat = button.closest('[data-repeat]'); + const template = repeat.querySelector(':scope > template[data-repeat-template]'); + const list = repeat.querySelector(':scope > [data-repeat-list]'); + list.append(template.content.cloneNode(true)); + list.lastElementChild?.querySelector('input:not([type="hidden"]), textarea, select')?.focus(); + return; + } + + const row = button.closest('[data-repeat-row]'); + if (button.hasAttribute('data-repeat-remove')) row.remove(); + else if (button.hasAttribute('data-repeat-up')) row.previousElementSibling?.before(row); + else row.nextElementSibling?.after(row); +}); + +function rowIndexes(field) { + const indexes = []; + for (let row = field.closest('[data-repeat-row]'); row; row = row.parentElement.closest('[data-repeat-row]')) { + const siblings = [...row.parentElement.children].filter((el) => el.matches('[data-repeat-row]')); + indexes.unshift(siblings.indexOf(row)); + } + return indexes; +} + +document.addEventListener('submit', (event) => { + const form = event.target; + if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) { + event.preventDefault(); + return; + } + for (const field of form.querySelectorAll('[data-name]')) { + const indexes = rowIndexes(field); + let i = 0; + field.name = field.dataset.name.replace(/#/g, () => indexes[i++] ?? 0); + } +}); + +// New project: fill the URL name from the German title until it is edited by hand +const slug = document.querySelector('[data-slug]'); +const title = document.querySelector('[data-title]'); +if (slug && title && !slug.value) { + let touched = false; + slug.addEventListener('input', () => (touched = true)); + title.addEventListener('input', () => { + if (touched) return; + slug.value = title.value + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 60) + .replace(/^-+|-+$/g, ''); + }); +} diff --git a/compose.yaml b/compose.yaml index 1c3ac32..73379f7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,9 +9,15 @@ services: required: false environment: SITE_URL: ${SITE_URL:-https://lux.harlelux.de} + ports: + # Admin-Seiten. Die FRITZ!Box gibt diesen Port nicht nach außen frei, + # er ist also nur im Heimnetz erreichbar: http://:3051/admin + - "${ADMIN_HOST_PORT:-3051}:3001" volumes: - # Inhalte kommen direkt aus dem Repo, nach einem git pull lädt die Seite sie neu + # Startinhalte aus dem Repo - ./content:/app/content:ro + # Alles, was über die Admin-Seiten bearbeitet wird. Gehört ins Backup. + - ./data:/app/data - cache:/app/cache read_only: true tmpfs: diff --git a/content/projects/personal-website/de.md b/content/projects/personal-website/de.md index 6abb615..1a37ade 100644 --- a/content/projects/personal-website/de.md +++ b/content/projects/personal-website/de.md @@ -1,14 +1,14 @@ --- title: Diese Website -summary: Eigene Seite mit Node.js und Express, Inhalte als Markdown-Dateien, ausgeliefert als Docker-Container. +summary: Eigene Seite mit Node.js und Express, gepflegt über eine kleine Admin-Oberfläche, ausgeliefert als Docker-Container. --- -Ich wollte eine Seite, die ich selbst verstehe und ohne CMS pflegen kann. Deshalb ist sie bewusst einfach aufgebaut. +Ich wollte eine Seite, die ich selbst verstehe und ohne fertiges CMS pflegen kann. Deshalb ist sie bewusst einfach aufgebaut. ## Aufbau - **Server:** Node.js mit Express, die Seiten rendert Nunjucks auf dem Server. -- **Inhalte:** Jedes Projekt ist ein Ordner mit einer YAML-Datei für die Metadaten und einer Markdown-Datei pro Sprache. Neue Einträge kommen per `git pull` auf den Server und werden ohne Neustart übernommen. +- **Inhalte:** Jedes Projekt ist ein Ordner mit einer YAML-Datei für die Metadaten und einer Markdown-Datei pro Sprache. Bearbeitet werden sie über Admin-Seiten, die nur im Heimnetz erreichbar sind. Änderungen sind sofort live, ohne Neustart. - **Bilder:** sharp rechnet sie beim ersten Aufruf in passende Größen um und legt sie in einem Cache ab. - **Betrieb:** Ein Docker-Container in Dockge, davor Nginx Proxy Manager für HTTPS. Der Container läuft mit schreibgeschütztem Dateisystem und ohne zusätzliche Rechte. diff --git a/content/projects/personal-website/en.md b/content/projects/personal-website/en.md index 81ca197..00a70a4 100644 --- a/content/projects/personal-website/en.md +++ b/content/projects/personal-website/en.md @@ -1,14 +1,14 @@ --- title: This website -summary: My own site built with Node.js and Express, content as Markdown files, shipped as a Docker container. +summary: My own site built with Node.js and Express, maintained through a small admin interface, shipped as a Docker container. --- -I wanted a site I fully understand and can maintain without a CMS, so it's deliberately simple. +I wanted a site I fully understand and can maintain without an off-the-shelf CMS, so it's deliberately simple. ## How it's built - **Server:** Node.js with Express, pages are rendered on the server with Nunjucks. -- **Content:** Every project is a folder with a YAML file for metadata and one Markdown file per language. New entries reach the server with `git pull` and are picked up without a restart. +- **Content:** Every project is a folder with a YAML file for metadata and one Markdown file per language. They're edited through admin pages that are only reachable from my home network. Changes go live right away, without a restart. - **Images:** sharp resizes them on first request and keeps the results in a cache. - **Hosting:** A Docker container managed in Dockge, with Nginx Proxy Manager in front for HTTPS. The container runs with a read-only filesystem and no extra privileges. diff --git a/package-lock.json b/package-lock.json index 19d698b..e873f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "express": "^5.3.0", "helmet": "^8.3.0", "marked": "^18.1.1", + "multer": "^2.5.0", "nunjucks": "^3.2.4", "sharp": "^0.35.5", "yaml": "^2.9.1" @@ -1091,6 +1092,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1144,6 +1151,17 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1779,6 +1797,67 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -2220,6 +2299,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 11afb92..73f2178 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "express": "^5.3.0", "helmet": "^8.3.0", "marked": "^18.1.1", + "multer": "^2.5.0", "nunjucks": "^3.2.4", "sharp": "^0.35.5", "yaml": "^2.9.1" diff --git a/scripts/build.js b/scripts/build.js index 654cc58..f750080 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -9,6 +9,8 @@ export function buildOptions({ dev = false } = {}) { entryPoints: [ { in: 'client/js/main.js', out: 'app' }, { in: 'client/css/main.css', out: 'app' }, + { in: 'client/js/admin.js', out: 'admin' }, + { in: 'client/css/admin.css', out: 'admin' }, ], outdir, bundle: true, diff --git a/src/admin/auth.js b/src/admin/auth.js new file mode 100644 index 0000000..48c113e --- /dev/null +++ b/src/admin/auth.js @@ -0,0 +1,86 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +const cookieName = 'lux_admin'; +const sessionTtl = 12 * 60 * 60 * 1000; +const maxFailures = 10; +const failureWindow = 15 * 60 * 1000; + +const digest = (value) => createHash('sha256').update(String(value)).digest(); + +export function passwordMatches(input, expected) { + return Boolean(expected) && timingSafeEqual(digest(input), digest(expected)); +} + +export function isPrivateAddress(address = '') { + const ip = address.replace(/^::ffff:/, ''); + if (ip === '::1' || ip.startsWith('127.')) return true; + if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true; + const docker = /^172\.(\d+)\./.exec(ip); + if (docker && Number(docker[1]) >= 16 && Number(docker[1]) <= 31) return true; + // fc00::/7 (unique local) and fe80::/10 (link local) + return /^f[cd][0-9a-f]{0,2}:/i.test(ip) || /^fe[89ab][0-9a-f]?:/i.test(ip); +} + +// Second line of defence next to the port not being forwarded by the router +export function homeNetworkOnly(req, res, next) { + if (isPrivateAddress(req.socket.remoteAddress)) return next(); + res.status(403).type('text').send('Nur im Heimnetz erreichbar.'); +} + +function readCookie(req, name) { + for (const part of (req.headers.cookie ?? '').split(';')) { + const [key, ...value] = part.trim().split('='); + if (key === name) return decodeURIComponent(value.join('=')); + } + return null; +} + +export function createAuth({ password }) { + // Sessions live in memory, a restart simply means logging in again + const sessions = new Map(); + const failures = new Map(); + + function current(req) { + const token = readCookie(req, cookieName); + const session = token ? sessions.get(token) : null; + if (!session) return null; + if (session.expires < Date.now()) { + sessions.delete(token); + return null; + } + return session; + } + + function login(req, res, input) { + const ip = req.socket.remoteAddress; + const now = Date.now(); + const entry = failures.get(ip); + if (entry && entry.until > now && entry.count >= maxFailures) return 'blocked'; + + if (!passwordMatches(input, password)) { + if (!entry || entry.until < now) failures.set(ip, { count: 1, until: now + failureWindow }); + else entry.count++; + return 'wrong'; + } + + failures.delete(ip); + const token = randomBytes(32).toString('base64url'); + sessions.set(token, { token, expires: now + sessionTtl, csrf: randomBytes(24).toString('base64url') }); + res.cookie(cookieName, token, { + httpOnly: true, + sameSite: 'strict', + secure: req.secure, + maxAge: sessionTtl, + path: '/', + }); + return 'ok'; + } + + function logout(req, res) { + const session = current(req); + if (session) sessions.delete(session.token); + res.clearCookie(cookieName, { path: '/' }); + } + + return { current, login, logout }; +} diff --git a/src/admin/files.js b/src/admin/files.js new file mode 100644 index 0000000..5d99e22 --- /dev/null +++ b/src/admin/files.js @@ -0,0 +1,200 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; +import YAML from 'yaml'; +import { parseFrontmatter } from '../content.js'; +import { InputError } from './forms.js'; + +export const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?$/; + +export function slugify(value) { + return String(value ?? '') + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 60) + .replace(/^-+|-+$/g, ''); +} + +const imageFormats = { jpeg: 'jpg', png: 'png', webp: 'webp', gif: 'gif', avif: 'avif' }; + +async function readText(file) { + try { + return await readFile(file, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +async function writeAtomic(file, text) { + await mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, text); + await rename(tmp, file); +} + +const toYaml = (data) => YAML.stringify(data, { lineWidth: 0 }); + +export function createFiles({ contentDir, dataDir }) { + // Reads prefer data/, writes always go there + const source = (rel) => { + const file = path.join(dataDir, rel); + return existsSync(file) ? file : path.join(contentDir, rel); + }; + + async function readYaml(rel) { + const raw = await readText(source(rel)); + return raw === null ? null : (YAML.parse(raw) ?? {}); + } + + const writeYaml = (rel, data) => writeAtomic(path.join(dataDir, rel), toYaml(data)); + + async function readMarkdown(rel) { + const raw = await readText(source(rel)); + return raw === null ? null : parseFrontmatter(raw); + } + + async function writeMarkdown(rel, data, body) { + await writeAtomic(path.join(dataDir, rel), `---\n${toYaml(data)}---\n\n${body.trim()}\n`); + } + + const removeFile = (rel) => rm(path.join(dataDir, rel), { force: true }); + + const projectDir = (slug) => path.join(dataDir, 'projects', slug); + + async function readProject(slug) { + if (!slugPattern.test(slug)) return null; + const dir = projectDir(slug); + const meta = await readText(path.join(dir, 'meta.yml')); + if (meta === null) return null; + const project = { slug, meta: YAML.parse(meta) ?? {} }; + for (const lang of ['de', 'en']) { + const raw = await readText(path.join(dir, `${lang}.md`)); + if (raw === null) continue; + const { data, body } = parseFrontmatter(raw); + project[lang] = { title: data.title ?? '', summary: data.summary ?? '', body: body.trim() }; + } + return project; + } + + async function imageFormat(upload) { + let meta; + try { + meta = await sharp(upload.buffer).metadata(); + } catch { + throw new InputError(`„${upload.originalname}“ ist kein Bild, das ich lesen kann.`); + } + if (!imageFormats[meta.format]) { + throw new InputError(`„${upload.originalname}“: Das Format wird nicht unterstützt. Möglich sind JPG, PNG, WebP, GIF und AVIF.`); + } + return meta.format; + } + + // Re-encoding drops EXIF data (camera, GPS position) and keeps uploads at a sensible size + async function saveImage(dir, upload, stem) { + const format = await imageFormat(upload); + const ext = imageFormats[format]; + + const base = slugify(stem ?? path.parse(upload.originalname).name) || 'bild'; + let name = `${base}.${ext}`; + for (let i = 2; existsSync(path.join(dir, name)); i++) name = `${base}-${i}.${ext}`; + + let image = sharp(upload.buffer, { animated: ext === 'gif' }) + .rotate() + .resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }); + image = ext === 'jpg' ? image.jpeg({ quality: 85, mozjpeg: true }) : image.toFormat(format); + await image.toFile(path.join(dir, name)); + return name; + } + + async function saveProject(oldSlug, input, uploads = {}) { + if (!input.de.title && !input.en.title) throw new InputError('Bitte mindestens einen Titel angeben.'); + const slug = input.slug || slugify(input.de.title || input.en.title); + if (!slugPattern.test(slug)) { + throw new InputError('Der URL-Name darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.'); + } + + // Check every upload before anything on disk changes + for (const upload of [...(uploads.cover ?? []), ...(uploads.images ?? [])]) await imageFormat(upload); + + const dir = projectDir(slug); + if (slug !== oldSlug && existsSync(dir)) throw new InputError(`Es gibt schon ein Projekt mit dem URL-Namen „${slug}“.`); + if (oldSlug && slug !== oldSlug) await rename(projectDir(oldSlug), dir); + await mkdir(dir, { recursive: true }); + + const meta = YAML.parse((await readText(path.join(dir, 'meta.yml'))) ?? '') ?? {}; + const files = new Set(await readdir(dir)); + const obsolete = []; + + let cover = meta.cover; + if (uploads.cover?.length) { + const name = await saveImage(dir, uploads.cover[0], 'cover'); + if (cover) obsolete.push(cover); + cover = name; + } else if (input.removeCover && cover) { + obsolete.push(cover); + cover = undefined; + } + + const gallery = []; + for (const item of input.gallery) { + if (!files.has(item.file)) continue; + if (item.remove) { + obsolete.push(item.file); + continue; + } + gallery.push(item.caption ? { src: item.file, caption: item.caption } : item.file); + } + for (const upload of uploads.images ?? []) gallery.push(await saveImage(dir, upload)); + + // Start from the existing file so fields the form doesn't know about survive + const next = { ...meta }; + const set = (key, value) => { + if (value === undefined || value === '' || value === false || (Array.isArray(value) && !value.length)) delete next[key]; + else next[key] = value; + }; + set('date', input.date); + set('tags', input.tags); + set('featured', input.featured); + set('draft', input.draft); + set('cover', cover); + const oldVideo = typeof meta.video === 'object' ? meta.video?.src : meta.video; + if (input.video !== oldVideo) set('video', input.video); + set('gallery', gallery); + set('links', input.links); + await writeAtomic(path.join(dir, 'meta.yml'), toYaml(next)); + + for (const lang of ['de', 'en']) { + const text = input[lang]; + const file = path.join(dir, `${lang}.md`); + if (!text.title && !text.summary && !text.body) { + await rm(file, { force: true }); + continue; + } + const front = { title: text.title || input[lang === 'de' ? 'en' : 'de'].title }; + if (text.summary) front.summary = text.summary; + await writeAtomic(file, `---\n${toYaml(front)}---\n\n${text.body}\n`); + } + + // Images that are still referenced in one of the texts stay + const texts = `${input.de.body}\n${input.en.body}`; + for (const file of obsolete) { + if (!texts.includes(file)) await rm(path.join(dir, file), { force: true }); + } + return slug; + } + + async function deleteProject(slug) { + if (!slugPattern.test(slug)) return; + await rm(projectDir(slug), { recursive: true, force: true }); + } + + return { readYaml, writeYaml, readMarkdown, writeMarkdown, removeFile, readProject, saveProject, deleteProject }; +} diff --git a/src/admin/forms.js b/src/admin/forms.js new file mode 100644 index 0000000..ce108eb --- /dev/null +++ b/src/admin/forms.js @@ -0,0 +1,254 @@ +// Turns form fields into the YAML structures the content loader reads, and back. + +export class InputError extends Error {} + +// Repeated rows arrive as arrays, or as objects with numeric keys once there are many of them +export function list(value) { + if (value === undefined || value === null || value === '') return []; + if (Array.isArray(value)) return value; + if (typeof value === 'object') { + return Object.keys(value) + .sort((a, b) => Number(a) - Number(b)) + .map((key) => value[key]); + } + return [value]; +} + +export const text = (value) => (typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : ''); +export const flag = (value) => value === 'on' || value === 'true' || value === '1'; +export const lines = (value) => text(value).split('\n').map((line) => line.trim()).filter(Boolean); +export const commas = (value) => text(value).split(',').map((part) => part.trim()).filter(Boolean); + +// A plain string when both languages are the same or English is empty, otherwise { de, en } +export function localized(pair) { + const de = text(pair?.de); + const en = text(pair?.en); + if (!en || en === de) return de || undefined; + if (!de) return { en }; + return { de, en }; +} + +export function split(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { de: value.de ?? '', en: value.en ?? '' }; + } + return { de: value ?? '', en: '' }; +} + +function pairLists(deItems, enItems) { + return deItems.map((de, i) => localized({ de, en: enItems[i] })).filter(Boolean); +} + +function joinLists(items, separator) { + const parts = (items ?? []).map(split); + const hasEnglish = parts.some((part) => part.en); + return { + de: parts.map((part) => part.de || part.en).join(separator), + en: hasEnglish ? parts.map((part) => part.en || part.de).join(separator) : '', + }; +} + +// Drops undefined values and empty arrays so the YAML stays readable +export function clean(object) { + return Object.fromEntries( + Object.entries(object).filter(([, value]) => value !== undefined && !(Array.isArray(value) && !value.length)), + ); +} + +function checkUrl(url, { allowFiles = false } = {}) { + if (/^(https?:\/\/|mailto:)/i.test(url)) return url; + if (allowFiles && /^[\w.-]+$/.test(url)) return url; + throw new InputError(`„${url}“ ist kein gültiger Link. Erlaubt sind http(s)://…${allowFiles ? ', mailto: oder ein Dateiname aus dem Projekt' : ' oder mailto:'}.`); +} + +function linkRows(value, options) { + return list(value) + .map((row) => ({ label: localized(row?.label), url: text(row?.url) })) + .filter((row) => row.url) + .map((row) => clean({ label: row.label, url: checkUrl(row.url, options) })); +} + +const linkForm = (links) => (links ?? []).map((link) => ({ label: split(link.label), url: link.url ?? '' })); + +/* Projects */ + +export function projectInput(body) { + const language = (key) => ({ + title: text(body[key]?.title), + summary: text(body[key]?.summary), + body: text(body[key]?.body), + }); + return { + slug: text(body.slug).toLowerCase(), + date: text(body.date), + tags: commas(body.tags).map((tag) => tag.toLowerCase()), + featured: flag(body.featured), + draft: flag(body.draft), + video: text(body.video), + links: linkRows(body.links, { allowFiles: true }), + removeCover: flag(body.removeCover), + gallery: list(body.gallery).map((item) => ({ + file: text(item?.file), + caption: localized(item?.caption), + remove: flag(item?.remove), + })), + de: language('de'), + en: language('en'), + }; +} + +export function projectForm(raw) { + const meta = raw?.meta ?? {}; + const video = typeof meta.video === 'object' ? meta.video?.src : meta.video; + return { + slug: raw?.slug ?? '', + date: meta.date !== undefined ? String(meta.date) : '', + tags: (meta.tags ?? []).join(', '), + featured: Boolean(meta.featured), + draft: Boolean(meta.draft), + video: video ?? '', + links: linkForm(meta.links), + cover: meta.cover ?? '', + gallery: (meta.gallery ?? []).map((item) => { + const entry = typeof item === 'string' ? { src: item } : item; + return { file: entry.src, caption: split(entry.caption) }; + }), + de: { title: '', summary: '', body: '', ...raw?.de }, + en: { title: '', summary: '', body: '', ...raw?.en }, + }; +} + +// After a failed save: keep what was typed, but images can only come from disk +export function projectFormFromInput(input, raw) { + const saved = projectForm(raw); + return { + ...saved, + slug: input.slug, + date: input.date, + tags: input.tags.join(', '), + featured: input.featured, + draft: input.draft, + video: input.video, + links: linkForm(input.links), + de: input.de, + en: input.en, + }; +} + +/* Journey */ + +const entryTypes = ['edu', 'work', 'project']; + +export function journeyInput(body) { + const entries = list(body.entries) + .map((entry) => + clean({ + title: localized(entry?.title), + place: localized(entry?.place), + text: localized(entry?.text), + type: entryTypes.includes(entry?.type) ? entry.type : undefined, + from: text(entry?.from) || undefined, + to: text(entry?.to) || undefined, + ongoing: flag(entry?.ongoing) || undefined, + draft: flag(entry?.draft) || undefined, + }), + ) + .filter((entry) => entry.title); + return { entries, about: { de: text(body.about?.de), en: text(body.about?.en) } }; +} + +export function journeyForm(yaml, about) { + return { + entries: (yaml?.entries ?? []).map((entry) => ({ + title: split(entry.title), + place: split(entry.place), + text: split(entry.text), + type: entry.type ?? '', + from: entry.from !== undefined ? String(entry.from) : '', + to: entry.to !== undefined ? String(entry.to) : '', + ongoing: Boolean(entry.ongoing), + draft: Boolean(entry.draft), + })), + about, + }; +} + +/* Profile: name, links, "currently", skills */ + +export function profileInput(body) { + const name = text(body.name); + if (!name) throw new InputError('Der Name darf nicht leer sein.'); + const email = text(body.email); + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new InputError('Die E-Mail-Adresse sieht nicht richtig aus.'); + + return { + name, + pronouns: localized(body.pronouns), + description: localized(body.description), + email, + links: linkRows(body.links).map((link) => ({ label: link.label ?? link.url, url: link.url })), + now: list(body.now).map(localized).filter(Boolean), + skills: list(body.skills) + .map((group) => clean({ group: localized(group?.group), items: pairLists(commas(group?.items?.de), commas(group?.items?.en)) })) + .filter((group) => group.group && group.items?.length), + }; +} + +export function profileForm(site) { + return { + name: site?.name ?? '', + pronouns: split(site?.pronouns), + description: split(site?.description), + email: site?.email ?? '', + links: linkForm(site?.links), + now: (site?.now ?? []).map(split), + skills: (site?.skills ?? []).map((group) => ({ group: split(group.group), items: joinLists(group.items, ', ') })), + }; +} + +/* Homelab */ + +export function homelabInput(body) { + return { + visible: flag(body.visible), + lead: { de: text(body.lead?.de), en: text(body.lead?.en) }, + body: { de: text(body.body?.de), en: text(body.body?.en) }, + hardware: list(body.hardware) + .map((machine) => + clean({ + name: text(machine?.name), + role: localized(machine?.role), + specs: pairLists(lines(machine?.specs?.de), lines(machine?.specs?.en)), + }), + ) + .filter((machine) => machine.name), + services: list(body.services) + .map((group) => + clean({ + group: localized(group?.group), + items: list(group?.items) + .map((item) => clean({ name: text(item?.name), url: text(item?.url) || undefined, text: localized(item?.text) })) + .filter((item) => item.name) + .map((item) => (item.url ? { ...item, url: checkUrl(item.url) } : item)), + }), + ) + .filter((group) => group.group && group.items?.length), + }; +} + +export function homelabForm(yaml, pages) { + return { + visible: !yaml?.draft, + lead: pages.lead, + body: pages.body, + hardware: (yaml?.hardware ?? []).map((machine) => ({ + name: machine.name ?? '', + role: split(machine.role), + specs: joinLists(machine.specs, '\n'), + })), + services: (yaml?.services ?? []).map((group) => ({ + group: split(group.group), + items: (group.items ?? []).map((item) => ({ name: item.name ?? '', url: item.url ?? '', text: split(item.text) })), + })), + }; +} diff --git a/src/admin/routes.js b/src/admin/routes.js new file mode 100644 index 0000000..2160bfe --- /dev/null +++ b/src/admin/routes.js @@ -0,0 +1,246 @@ +import express from 'express'; +import multer from 'multer'; +import { mediaUrl } from '../images.js'; +import { createAuth } from './auth.js'; +import { createFiles } from './files.js'; +import * as forms from './forms.js'; + +const notices = { + saved: 'Gespeichert.', + created: 'Projekt angelegt.', + deleted: 'Projekt gelöscht.', +}; + +export function adminRouter({ config, content, stores }) { + const router = express.Router(); + const auth = createAuth({ password: config.adminPassword }); + const files = createFiles(config); + const urlencoded = express.urlencoded({ extended: true, limit: '2mb', parameterLimit: 10000 }); + const uploads = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024, files: 30, fields: 2000 }, + }).fields([ + { name: 'cover', maxCount: 1 }, + { name: 'images', maxCount: 30 }, + ]); + const reload = () => Promise.all(stores.map((store) => store.reload())); + + function view(res, template, data = {}) { + res.render(`admin/${template}.njk`, { notice: notices[res.req.query.ok], ...data }); + } + + router.use((req, res, next) => { + res.set('Cache-Control', 'no-store'); + if (req.method === 'POST') { + const origin = req.get('origin'); + if (origin && origin !== 'null' && new URL(origin).host !== req.get('host')) { + return res.status(403).type('text').send('Fremde Herkunft.'); + } + } + next(); + }); + + router.get('/admin/login', (req, res) => { + if (auth.current(req)) return res.redirect(303, '/admin'); + view(res, 'login'); + }); + + router.post('/admin/login', urlencoded, (req, res) => { + const result = auth.login(req, res, req.body?.password ?? ''); + if (result === 'ok') return res.redirect(303, '/admin'); + res.status(result === 'blocked' ? 429 : 401); + view(res, 'login', { + error: result === 'blocked' ? 'Zu viele Fehlversuche. Bitte warte eine Viertelstunde.' : 'Das Passwort stimmt nicht.', + }); + }); + + // Everything below, including the preview of the site with drafts, needs a login + router.use((req, res, next) => { + const session = auth.current(req); + if (!session) { + if (req.method === 'GET') return res.redirect(303, '/admin/login'); + return res.status(403).type('text').send('Bitte zuerst anmelden.'); + } + res.locals.csrf = session.csrf; + res.locals.loggedIn = true; + res.locals.siteUrl = config.siteUrl; + next(); + }); + + const csrf = (req, res, next) => { + if (req.body?._csrf === res.locals.csrf) return next(); + res.status(403).type('text').send('Das Formular ist abgelaufen. Bitte lade die Seite neu und versuch es noch einmal.'); + }; + + const withUploads = (req, res, next) => { + uploads(req, res, (err) => { + if (!err) return next(); + if (!(err instanceof multer.MulterError)) return next(err); + const reason = err.code === 'LIMIT_FILE_SIZE' ? 'Ein Bild ist größer als 25 MB.' : 'Zu viele Dateien auf einmal.'; + res.status(413).type('text').send(`${reason} Geh zurück und versuch es mit weniger oder kleineren Bildern.`); + }); + }; + + router.post('/admin/logout', urlencoded, csrf, (req, res) => { + auth.logout(req, res); + res.redirect(303, '/admin/login'); + }); + + /* Projects */ + + const projectView = (res, form, extra = {}) => { + const thumb = (file) => mediaUrl(`projects/${form.slug}/${file}`, { width: 480 }); + view(res, 'project', { + section: 'projects', + project: form, + coverUrl: form.cover ? thumb(form.cover) : null, + gallery: form.gallery.map((item) => ({ ...item, url: thumb(item.file) })), + ...extra, + }); + }; + + router.get('/admin', (req, res) => { + // first srcset entry is the smallest version, enough for the list + const projects = content.get().projects.map((project) => ({ + ...project, + thumb: project.cover ? (project.cover.srcset?.split(' ')[0] ?? project.cover.src) : null, + })); + view(res, 'projects', { section: 'projects', projects }); + }); + + router.get('/admin/projekte/neu', (req, res) => { + projectView(res, forms.projectForm(null), { isNew: true }); + }); + + async function saveProject(req, res, oldSlug) { + const raw = oldSlug ? await files.readProject(oldSlug) : null; + if (oldSlug && !raw) return res.status(404).type('text').send('Projekt nicht gefunden.'); + let input; + try { + input = forms.projectInput(req.body); + const slug = await files.saveProject(oldSlug, input, req.files ?? {}); + await reload(); + res.redirect(303, `/admin/projekte/${slug}?ok=${oldSlug ? 'saved' : 'created'}`); + } catch (err) { + if (!(err instanceof forms.InputError)) throw err; + res.status(422); + const form = input ? forms.projectFormFromInput(input, raw) : forms.projectForm(raw); + if (!oldSlug) form.slug = input?.slug ?? ''; + projectView(res, form, { isNew: !oldSlug, error: err.message, savedSlug: oldSlug }); + } + } + + router.post('/admin/projekte/neu', withUploads, csrf, (req, res) => saveProject(req, res, null)); + + router.get('/admin/projekte/:slug', async (req, res, next) => { + const raw = await files.readProject(req.params.slug); + if (!raw) return next(); + projectView(res, forms.projectForm(raw), { savedSlug: raw.slug }); + }); + + router.post('/admin/projekte/:slug', withUploads, csrf, (req, res) => saveProject(req, res, req.params.slug)); + + router.post('/admin/projekte/:slug/loeschen', urlencoded, csrf, async (req, res) => { + await files.deleteProject(req.params.slug); + await reload(); + res.redirect(303, '/admin?ok=deleted'); + }); + + /* Journey */ + + async function journeyForm() { + const about = {}; + for (const lang of ['de', 'en']) about[lang] = (await files.readMarkdown(`pages/journey.${lang}.md`))?.body.trim() ?? ''; + return forms.journeyForm(await files.readYaml('journey.yml'), about); + } + + router.get('/admin/werdegang', async (req, res) => { + view(res, 'journey', { section: 'journey', form: await journeyForm() }); + }); + + router.post('/admin/werdegang', urlencoded, csrf, async (req, res) => { + const input = forms.journeyInput(req.body); + await files.writeYaml('journey.yml', { entries: input.entries }); + for (const [lang, title] of [['de', 'Werdegang'], ['en', 'Journey']]) { + const rel = `pages/journey.${lang}.md`; + if (lang === 'en' && !input.about.en) { + await files.removeFile(rel); + continue; + } + const existing = await files.readMarkdown(rel); + await files.writeMarkdown(rel, existing?.data ?? { title }, input.about[lang]); + } + await reload(); + res.redirect(303, '/admin/werdegang?ok=saved'); + }); + + /* Profile */ + + router.get('/admin/profil', async (req, res) => { + view(res, 'profile', { section: 'profile', form: forms.profileForm(await files.readYaml('site.yml')) }); + }); + + router.post('/admin/profil', urlencoded, csrf, async (req, res) => { + const site = (await files.readYaml('site.yml')) ?? {}; + let input; + try { + input = forms.profileInput(req.body); + } catch (err) { + if (!(err instanceof forms.InputError)) throw err; + res.status(422); + return view(res, 'profile', { section: 'profile', form: forms.profileForm(site), error: err.message }); + } + await files.writeYaml('site.yml', forms.clean({ ...site, ...input, email: input.email || '' })); + await reload(); + res.redirect(303, '/admin/profil?ok=saved'); + }); + + /* Homelab */ + + async function homelabPages() { + const lead = {}; + const body = {}; + for (const lang of ['de', 'en']) { + const page = await files.readMarkdown(`pages/homelab.${lang}.md`); + lead[lang] = page?.data.lead ?? ''; + body[lang] = page?.body.trim() ?? ''; + } + return { lead, body }; + } + + router.get('/admin/homelab', async (req, res) => { + const form = forms.homelabForm(await files.readYaml('homelab.yml'), await homelabPages()); + view(res, 'homelab', { section: 'homelab', form }); + }); + + router.post('/admin/homelab', urlencoded, csrf, async (req, res) => { + let input; + try { + input = forms.homelabInput(req.body); + } catch (err) { + if (!(err instanceof forms.InputError)) throw err; + res.status(422); + const form = forms.homelabForm(await files.readYaml('homelab.yml'), await homelabPages()); + return view(res, 'homelab', { section: 'homelab', form, error: err.message }); + } + const existing = (await files.readYaml('homelab.yml')) ?? {}; + await files.writeYaml( + 'homelab.yml', + forms.clean({ ...existing, draft: input.visible ? undefined : true, hardware: input.hardware, services: input.services }), + ); + for (const lang of ['de', 'en']) { + const rel = `pages/homelab.${lang}.md`; + if (lang === 'en' && !input.lead.en && !input.body.en) { + await files.removeFile(rel); + continue; + } + const existingPage = await files.readMarkdown(rel); + const front = forms.clean({ ...existingPage?.data, title: existingPage?.data.title ?? 'Homelab', lead: input.lead[lang] || undefined }); + await files.writeMarkdown(rel, front, input.body[lang]); + } + await reload(); + res.redirect(303, '/admin/homelab?ok=saved'); + }); + + return router; +} diff --git a/src/app.js b/src/app.js index 43c9ff9..24278aa 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,8 @@ import helmet from 'helmet'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import nunjucks from 'nunjucks'; +import { homeNetworkOnly } from './admin/auth.js'; +import { adminRouter } from './admin/routes.js'; import { loadConfig, rootDir } from './config.js'; import { createContentStore } from './content.js'; import { formatDate, localize, locales, pickLocale } from './i18n.js'; @@ -52,14 +54,8 @@ function setupViews(app, config) { return env; } -export async function createApp(options = {}) { - const config = options.config ?? loadConfig(); - const content = options.content ?? createContentStore(config); - if (!content.get()) await content.reload(); - - const app = express(); +function setupCommon(app, config, { upgradeInsecureRequests }) { setupViews(app, config); - app.use( helmet({ contentSecurityPolicy: { @@ -76,7 +72,7 @@ export async function createApp(options = {}) { frameAncestors: ["'none'"], baseUri: ["'self'"], objectSrc: ["'none'"], - upgradeInsecureRequests: config.production ? [] : null, + upgradeInsecureRequests: upgradeInsecureRequests ? [] : null, }, }, // HSTS is set by the reverse proxy @@ -91,10 +87,10 @@ export async function createApp(options = {}) { express.static(path.join(rootDir, 'dist', 'assets'), { immutable: true, maxAge: '365d', index: false }), ); app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false })); - app.get('/media/*file', mediaHandler(config)); +} - const deps = { config, content }; - app.use(seoRouter(deps)); +function mountSite(app, deps) { + app.get('/media/*file', mediaHandler(deps.config)); app.get('/', (req, res) => { res.vary('Accept-Language'); @@ -113,6 +109,31 @@ export async function createApp(options = {}) { const lang = locales.find((l) => req.path.startsWith(`/${l}/`)) ?? 'de'; renderError(req, res, err.status && err.status < 500 ? err.status : 500, lang, deps); }); +} +export async function createApp(options = {}) { + const config = options.config ?? loadConfig(); + const content = options.content ?? createContentStore(config); + if (!content.get()) await content.reload(); + + const app = express(); + setupCommon(app, config, { upgradeInsecureRequests: config.production }); + app.use(seoRouter({ config, content })); + mountSite(app, { config, content }); + return app; +} + +// Admin pages plus the whole site with drafts, on a separate port for the home network. +// `content` must be a store that includes drafts, `stores` are reloaded after every change. +export async function createAdminApp({ config, content, stores = [content] }) { + if (!content.get()) await content.reload(); + + const app = express(); + app.use(homeNetworkOnly); + // Plain HTTP inside the LAN, so no upgrade to https here + setupCommon(app, config, { upgradeInsecureRequests: false }); + app.get('/', (req, res) => res.redirect('/admin')); + app.use(adminRouter({ config, content, stores })); + mountSite(app, { config: { ...config, showDrafts: true }, content }); return app; } diff --git a/src/config.js b/src/config.js index fb72115..542976d 100644 --- a/src/config.js +++ b/src/config.js @@ -13,8 +13,12 @@ export function loadConfig(overrides = {}) { port: Number(env.PORT) || 3000, siteUrl: (env.SITE_URL || 'http://localhost:3000').replace(/\/+$/, ''), contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'), + dataDir: path.resolve(rootDir, env.DATA_DIR || 'data'), cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'), showDrafts: !production, + // The admin pages run on their own port, which is only reachable from the home network + adminPort: Number(env.ADMIN_PORT) || 3001, + adminPassword: env.ADMIN_PASSWORD || '', ...overrides, }; } diff --git a/src/content.js b/src/content.js index 930d4b0..0833b6d 100644 --- a/src/content.js +++ b/src/content.js @@ -1,4 +1,4 @@ -import { watch } from 'node:fs'; +import { existsSync, watch } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { Marked } from 'marked'; @@ -29,8 +29,9 @@ const escapeAttr = (value) => String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); class Loader { - constructor(dir, { showDrafts }) { - this.dir = dir; + // Earlier roots win: data/ (written by the admin pages) comes before content/ from the repo + constructor(roots, { showDrafts }) { + this.roots = roots; this.showDrafts = showDrafts; this.warnings = []; } @@ -39,8 +40,20 @@ class Loader { this.warnings.push(message); } + find(rel) { + for (const root of this.roots) { + const file = path.join(root, rel); + if (existsSync(file)) return file; + } + return path.join(this.roots.at(-1), rel); + } + + rootOf(file) { + return this.roots.find((root) => file.startsWith(root + path.sep)); + } + rel(file) { - return path.relative(this.dir, file).split(path.sep).join('/'); + return path.relative(this.rootOf(file) ?? this.roots.at(-1), file).split(path.sep).join('/'); } async readText(file) { @@ -65,8 +78,9 @@ class Loader { // Relative path from a content file -> served URL, or null if it points somewhere we don't serve. async media(baseDir, href) { const file = path.resolve(baseDir, decodeURI(href)); + const root = this.rootOf(file); const rel = this.rel(file); - if (!resolveMedia(this.dir, rel)) { + if (!root || !resolveMedia(root, rel)) { this.warn(`${rel}: outside of content/ or unsupported file type`); return null; } @@ -143,14 +157,15 @@ class Loader { return marked.parse(body); } - // Reads .de.md / .en.md and fills a missing language from the other one. - async localizedMarkdown(baseDir, name) { + // Reads pages/.de.md / .en.md and fills a missing language from the other one. + async localizedMarkdown(name) { const texts = {}; for (const lang of locales) { - const source = await this.readText(path.join(baseDir, `${name}.${lang}.md`)); + const file = this.find(`pages/${name}.${lang}.md`); + const source = await this.readText(file); if (source === null) continue; const { data, body } = parseFrontmatter(source); - texts[lang] = { ...data, html: await this.markdown(body, baseDir) }; + texts[lang] = { ...data, html: await this.markdown(body, path.dirname(file)) }; } for (const lang of locales) { const other = otherLocale(lang); @@ -182,8 +197,8 @@ class Loader { }; } - async project(slug) { - const dir = path.join(this.dir, 'projects', slug); + async project(projectsDir, slug) { + const dir = path.join(projectsDir, slug); const meta = await this.readYaml(path.join(dir, 'meta.yml')); if (!meta) { this.warn(`projects/${slug}: meta.yml missing, skipped`); @@ -245,18 +260,15 @@ class Loader { } async projects() { - let entries = []; - try { - entries = await readdir(path.join(this.dir, 'projects'), { withFileTypes: true }); - } catch (err) { - if (err.code !== 'ENOENT') throw err; - } + // Projects are never mixed: once data/projects exists, the repo copy is ignored + const projectsDir = this.roots.map((root) => path.join(root, 'projects')).find((dir) => existsSync(dir)); + const entries = projectsDir ? await readdir(projectsDir, { withFileTypes: true }) : []; const projects = []; for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue; try { - const project = await this.project(entry.name); + const project = await this.project(projectsDir, entry.name); if (project && (this.showDrafts || !project.draft)) projects.push(project); } catch (err) { this.warn(`${err.message}, skipped`); @@ -266,22 +278,23 @@ class Loader { } async load() { - const site = (await this.readYaml(path.join(this.dir, 'site.yml'))) ?? {}; + const siteFile = this.find('site.yml'); + const site = (await this.readYaml(siteFile)) ?? {}; if (!site.name) this.warn('site.yml: name missing'); - site.avatar = await this.image(this.dir, site.avatar, site.name ?? ''); + site.avatar = await this.image(path.dirname(siteFile), site.avatar, site.name ?? ''); - const home = (await this.readYaml(path.join(this.dir, 'home.yml'))) ?? {}; + const home = (await this.readYaml(this.find('home.yml'))) ?? {}; - const journey = ((await this.readYaml(path.join(this.dir, 'journey.yml')))?.entries ?? []).filter( + const journey = ((await this.readYaml(this.find('journey.yml')))?.entries ?? []).filter( (entry) => entry && (this.showDrafts || !entry.draft), ); - let homelab = (await this.readYaml(path.join(this.dir, 'homelab.yml'))) ?? null; + let homelab = (await this.readYaml(this.find('homelab.yml'))) ?? null; if (homelab?.draft && !this.showDrafts) homelab = null; const pages = {}; for (const name of textPages) { - pages[name] = await this.localizedMarkdown(path.join(this.dir, 'pages'), name); + pages[name] = await this.localizedMarkdown(name); } const projects = await this.projects(); @@ -297,19 +310,19 @@ class Loader { } } -export async function loadContent(dir, options = {}) { - const loader = new Loader(dir, { showDrafts: false, ...options }); +export async function loadContent(contentDir, { dataDir, showDrafts = false } = {}) { + const loader = new Loader([dataDir, contentDir].filter(Boolean), { showDrafts }); const content = await loader.load(); return { content, warnings: loader.warnings }; } -export function createContentStore({ contentDir, showDrafts }) { +export function createContentStore({ contentDir, dataDir, showDrafts }) { let current = null; let timer = null; - let watcher = null; + const watchers = []; async function reload() { - const { content, warnings } = await loadContent(contentDir, { showDrafts }); + const { content, warnings } = await loadContent(contentDir, { dataDir, showDrafts }); for (const warning of warnings) console.warn(`[content] ${warning}`); current = content; return content; @@ -330,13 +343,17 @@ export function createContentStore({ contentDir, showDrafts }) { get: () => current, reload, watch() { - watcher = watch(contentDir, { recursive: true }, schedule); - watcher.on('error', (err) => console.error(`[content] watcher: ${err.message}`)); + for (const dir of [contentDir, dataDir]) { + if (!dir || !existsSync(dir)) continue; + const watcher = watch(dir, { recursive: true }, schedule); + watcher.on('error', (err) => console.error(`[content] watcher: ${err.message}`)); + watchers.push(watcher); + } process.on('SIGHUP', schedule); }, close() { clearTimeout(timer); - watcher?.close(); + for (const watcher of watchers) watcher.close(); process.off('SIGHUP', schedule); }, }; diff --git a/src/data.js b/src/data.js new file mode 100644 index 0000000..e25748d --- /dev/null +++ b/src/data.js @@ -0,0 +1,30 @@ +import { existsSync } from 'node:fs'; +import { cp, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +// Everything the admin pages can change. On the very first start it is copied from +// content/ into data/, after that data/ is the only place it lives. +export const editable = [ + 'site.yml', + 'journey.yml', + 'homelab.yml', + 'pages/journey.de.md', + 'pages/journey.en.md', + 'pages/homelab.de.md', + 'pages/homelab.en.md', + 'projects', +]; + +export async function seedData({ contentDir, dataDir }) { + const marker = path.join(dataDir, '.seeded'); + if (existsSync(marker)) return false; + + await mkdir(dataDir, { recursive: true }); + for (const rel of editable) { + const from = path.join(contentDir, rel); + if (!existsSync(from)) continue; + await cp(from, path.join(dataDir, rel), { recursive: true, force: false, errorOnExist: false }); + } + await writeFile(marker, `${new Date().toISOString()}\n`); + return true; +} diff --git a/src/images.js b/src/images.js index 403f248..8df8240 100644 --- a/src/images.js +++ b/src/images.js @@ -34,21 +34,28 @@ export function resolveMedia(contentDir, rel) { return file.startsWith(contentDir + path.sep) ? file : null; } -export function mediaHandler({ contentDir, cacheDir }) { +export function mediaHandler({ contentDir, dataDir, cacheDir }) { + const roots = [dataDir, contentDir].filter(Boolean); const pending = new Map(); return async (req, res, next) => { const rel = [].concat(req.params.file).join('/'); - const file = resolveMedia(contentDir, rel); - if (!file) return next(); - + let file; let info; - try { - info = await stat(file); - } catch { - return next(); + for (const root of roots) { + const candidate = resolveMedia(root, rel); + if (!candidate) continue; + try { + info = await stat(candidate); + } catch { + continue; + } + if (info.isFile()) { + file = candidate; + break; + } } - if (!info.isFile()) return next(); + if (!file) return next(); // URLs built by the content loader carry ?v=, those can be cached for good const versioned = 'v' in req.query; diff --git a/src/server.js b/src/server.js index 0f23d6e..5f34d6e 100644 --- a/src/server.js +++ b/src/server.js @@ -1,26 +1,54 @@ -import { createApp } from './app.js'; +import { constants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import { createAdminApp, createApp } from './app.js'; import { loadConfig } from './config.js'; import { createContentStore } from './content.js'; +import { seedData } from './data.js'; const config = loadConfig(); -const content = createContentStore(config); +let dataWritable = true; +try { + if (await seedData(config)) console.log(`[data] copied the editable content to ${config.dataDir}`); + await access(config.dataDir, constants.W_OK); +} catch (err) { + dataWritable = false; + console.error(`[data] ${config.dataDir} is not writable (${err.code ?? err.message}), admin pages stay off`); +} + +const content = createContentStore(config); try { await content.reload(); } catch (err) { - console.error(`[content] could not load ${config.contentDir}: ${err.message}`); + console.error(`[content] could not load the content: ${err.message}`); process.exit(1); } content.watch(); const app = await createApp({ config, content }); -const server = app.listen(config.port, () => { - console.log(`listening on http://localhost:${config.port} (${config.production ? 'production' : 'development'})`); -}); +const servers = [ + app.listen(config.port, () => { + console.log(`listening on http://localhost:${config.port} (${config.production ? 'production' : 'development'})`); + }), +]; + +const drafts = createContentStore({ ...config, showDrafts: true }); +if (!config.adminPassword) { + console.log('[admin] ADMIN_PASSWORD is not set, admin pages are off'); +} else if (dataWritable) { + await drafts.reload(); + drafts.watch(); + const admin = await createAdminApp({ config, content: drafts, stores: [content, drafts] }); + servers.push( + admin.listen(config.adminPort, () => console.log(`admin pages on http://localhost:${config.adminPort}/admin`)), + ); +} function shutdown() { content.close(); - server.close(() => process.exit(0)); + drafts.close(); + let open = servers.length; + for (const server of servers) server.close(() => --open || process.exit(0)); setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', shutdown); diff --git a/src/views/admin/homelab.njk b/src/views/admin/homelab.njk new file mode 100644 index 0000000..d22e288 --- /dev/null +++ b/src/views/admin/homelab.njk @@ -0,0 +1,49 @@ +{% extends "admin/layout.njk" %} +{% from "admin/macros.njk" import check, pair, machineRow, serviceGroupRow %} +{% set title = 'Homelab' %} + +{% block content %} +
+

Homelab

+ Vorschau +
+ +
+ + +
+

Seite

+
+ {{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }} +
+ {{ pair('Einleitung', 'lead', form.lead, rows=2) }} + {{ pair('Text unter Hardware und Diensten', 'body', form.body, rows=7, hint='Markdown geht, z. B. „## Was ich dabei gelernt habe“ als Überschrift.') }} +
+ +
+

Hardware

+
+
+ {% for machine in form.hardware %}{{ machineRow(machine) }}{% endfor %} +
+ + +
+
+ +
+

Dienste

+
+
+ {% for group in form.services %}{{ serviceGroupRow(group) }}{% endfor %} +
+ + +
+
+ +
+ +
+
+{% endblock %} diff --git a/src/views/admin/journey.njk b/src/views/admin/journey.njk new file mode 100644 index 0000000..839d451 --- /dev/null +++ b/src/views/admin/journey.njk @@ -0,0 +1,35 @@ +{% extends "admin/layout.njk" %} +{% from "admin/macros.njk" import pair, entryRow %} +{% set title = 'Werdegang' %} + +{% block content %} +
+

Werdegang

+ Vorschau +
+ +
+ + +
+

Über mich

+ {{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional.') }} +
+ +
+

Stationen

+

Neueste zuerst. Die Reihenfolge änderst du mit „Hoch“ und „Runter“.

+
+
+ {% for entry in form.entries %}{{ entryRow(entry) }}{% endfor %} +
+ + +
+
+ +
+ +
+
+{% endblock %} diff --git a/src/views/admin/layout.njk b/src/views/admin/layout.njk new file mode 100644 index 0000000..73a47ec --- /dev/null +++ b/src/views/admin/layout.njk @@ -0,0 +1,40 @@ + + + + + + + {% if title %}{{ title }} · {% endif %}Admin + + + + + + {% if loggedIn %} +
+
+ Admin + +
+ Vorschau + Live +
+ + +
+
+
+
+ {% endif %} +
+ + {% if notice %}

{{ notice }}

{% endif %} + {% if error %}{% endif %} + {% block content %}{% endblock %} +
+ + diff --git a/src/views/admin/login.njk b/src/views/admin/login.njk new file mode 100644 index 0000000..335d3e3 --- /dev/null +++ b/src/views/admin/login.njk @@ -0,0 +1,16 @@ +{% extends "admin/layout.njk" %} +{% set title = 'Anmelden' %} + +{% block content %} + +{% endblock %} diff --git a/src/views/admin/macros.njk b/src/views/admin/macros.njk new file mode 100644 index 0000000..970166c --- /dev/null +++ b/src/views/admin/macros.njk @@ -0,0 +1,144 @@ +{# Field names may contain "#" for the index of a repeated row, admin.js fills them in on submit #} + +{% macro field(label, name, value='', type='text', hint='', attrs='') -%} + +{%- endmacro %} + +{% macro area(label, name, value='', rows=3, hint='', mono=false) -%} + +{%- endmacro %} + +{% macro check(label, name, checked=false) -%} + +{%- endmacro %} + +{% macro select(label, name, value, options) -%} + +{%- endmacro %} + +{# Same field in German and English side by side #} +{% macro pair(label, name, value, rows=0, hint='', mono=false) -%} +
+ {{ label }} +
+ {% for lang in ['de', 'en'] %} + {% set full = name + '[' + lang + ']' %} + + {% endfor %} +
+ {% if hint %}

{{ hint }}

{% endif %} +
+{%- endmacro %} + +{% macro rowTools() -%} +
+ + + +
+{%- endmacro %} + +{% macro linkRow(prefix, link) -%} +
+
+ {{ field('Beschriftung', prefix + '[#][label][de]', link.label.de) }} + {{ field('Beschriftung EN (optional)', prefix + '[#][label][en]', link.label.en) }} + {{ field('Adresse', prefix + '[#][url]', link.url, hint='https://…, mailto:… oder ein Dateiname') }} +
+ {{ rowTools() }} +
+{%- endmacro %} + +{% macro entryRow(entry) -%} +
+ {{ pair('Titel', 'entries[#][title]', entry.title) }} +
+ {{ field('Von', 'entries[#][from]', entry.from, hint='z. B. 2024 oder 2024-10') }} + {{ field('Bis', 'entries[#][to]', entry.to, hint='leer, wenn es noch läuft') }} + {{ select('Art', 'entries[#][type]', entry.type, [['edu', 'Studium / Ausbildung'], ['work', 'Arbeit'], ['project', 'Projekt'], ['', 'Sonstiges']]) }} +
+
+ {{ check('Läuft noch (wird als „seit …“ angezeigt)', 'entries[#][ongoing]', entry.ongoing) }} + {{ check('Entwurf', 'entries[#][draft]', entry.draft) }} +
+ {{ pair('Ort', 'entries[#][place]', entry.place) }} + {{ pair('Beschreibung', 'entries[#][text]', entry.text, rows=3) }} + {{ rowTools() }} +
+{%- endmacro %} + +{% macro nowRow(item) -%} +
+ {{ pair('Eintrag', 'now[#]', item) }} + {{ rowTools() }} +
+{%- endmacro %} + +{% macro skillRow(group) -%} +
+ {{ pair('Gruppe', 'skills[#][group]', group.group) }} + {{ pair('Einträge', 'skills[#][items]', group.items, rows=2, hint='Mit Komma getrennt. Englisch nur ausfüllen, wenn sich etwas unterscheidet, dann in derselben Reihenfolge.') }} + {{ rowTools() }} +
+{%- endmacro %} + +{% macro machineRow(machine) -%} +
+
+ {{ field('Name', 'hardware[#][name]', machine.name) }} +
+ {{ pair('Aufgabe', 'hardware[#][role]', machine.role) }} + {{ pair('Daten', 'hardware[#][specs]', machine.specs, rows=4, hint='Eine Angabe pro Zeile, z. B. CPU, RAM, Speicher.') }} + {{ rowTools() }} +
+{%- endmacro %} + +{% macro serviceRow(item) -%} +
+
+ {{ field('Name', 'services[#][items][#][name]', item.name) }} + {{ field('Link (optional)', 'services[#][items][#][url]', item.url) }} +
+ {{ pair('Beschreibung', 'services[#][items][#][text]', item.text) }} + {{ rowTools() }} +
+{%- endmacro %} + +{% macro serviceGroupRow(group) -%} +
+ {{ pair('Gruppe', 'services[#][group]', group.group) }} +
+
+ {% for item in group.items %}{{ serviceRow(item) }}{% endfor %} +
+ + +
+ {{ rowTools() }} +
+{%- endmacro %} diff --git a/src/views/admin/profile.njk b/src/views/admin/profile.njk new file mode 100644 index 0000000..5dc9761 --- /dev/null +++ b/src/views/admin/profile.njk @@ -0,0 +1,64 @@ +{% extends "admin/layout.njk" %} +{% from "admin/macros.njk" import field, pair, linkRow, nowRow, skillRow %} +{% set title = 'Profil' %} + +{% block content %} +
+

Profil

+ Vorschau +
+ +
+ + +
+

Allgemein

+
+ {{ field('Name', 'name', form.name, attrs='required') }} + {{ field('E-Mail', 'email', form.email, type='email', hint='Steht auf der Kontaktseite und im Footer. Leer lassen, wenn sie nicht öffentlich sein soll.') }} +
+ {{ pair('Pronomen', 'pronouns', form.pronouns) }} + {{ pair('Beschreibung', 'description', form.description, rows=2, hint='Für Suchmaschinen und Link-Vorschauen.') }} +
+ +
+

Gerade dran

+

Die kleine Box auf der Startseite. Ohne Einträge wird sie ausgeblendet.

+
+
+ {% for item in form.now %}{{ nowRow(item) }}{% endfor %} +
+ + +
+
+ +
+

Links

+

Profile woanders, z. B. Git, LinkedIn. Erscheinen auf der Kontaktseite und im Footer.

+
+
+ {% for link in form.links %}{{ linkRow('links', link) }}{% endfor %} +
+ + +
+
+ +
+

Kenntnisse

+

Erscheinen auf der Werdegang-Seite und im Laufband auf der Startseite.

+
+
+ {% for group in form.skills %}{{ skillRow(group) }}{% endfor %} +
+ + +
+
+ +
+ +
+
+{% endblock %} diff --git a/src/views/admin/project.njk b/src/views/admin/project.njk new file mode 100644 index 0000000..6896905 --- /dev/null +++ b/src/views/admin/project.njk @@ -0,0 +1,104 @@ +{% extends "admin/layout.njk" %} +{% from "admin/macros.njk" import field, area, check, linkRow %} +{% set title = 'Neues Projekt' if isNew else (project.de.title or project.en.title or savedSlug) %} + +{% block content %} +
+

{{ title }}

+ {% if not isNew %}Vorschau{% endif %} +
+ +
+ + +
+

Allgemein

+
+ {{ field('URL-Name', 'slug', project.slug, hint='Teil der Adresse: /portfolio/' + (project.slug or 'mein-projekt') + '. Leer lassen, dann wird er aus dem Titel gebildet.', attrs='pattern="[a-z0-9\\-]*" spellcheck="false" data-slug') }} + {{ field('Datum', 'date', project.date, hint='Jahr, Monat oder Tag, z. B. 2026-10', attrs='pattern="\\d{4}(-\\d{2}){0,2}"') }} + {{ field('Tags', 'tags', project.tags, hint='Mit Komma getrennt, z. B. homelab, docker') }} +
+
+ {{ check('Entwurf (nur in der Vorschau sichtbar)', 'draft', project.draft) }} + {{ check('Auf der Startseite zeigen', 'featured', project.featured) }} +
+
+ +
+

Texte

+

Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder aus der Galerie bindest du mit ![Beschreibung](dateiname.jpg) ein.

+
+ {% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %} +
+

{{ label }}

+ {{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }} + {{ area('Kurzbeschreibung', lang + '[summary]', project[lang].summary, 2) }} + {{ area('Text', lang + '[body]', project[lang].body, 16, mono=true) }} +
+ {% endfor %} +
+
+ +
+

Bilder

+
+
+ {% if coverUrl %}{% else %}Kein Cover{% endif %} +
+
+ + {% if coverUrl %}{{ check('Cover entfernen', 'removeCover') }}{% endif %} +
+
+ +

Galerie

+ {% if gallery.length %} + + {% endif %} + +
+ +
+

Links und Video

+ {{ field('YouTube-Link', 'video', project.video, hint='Wird auf der Seite erst nach einem Klick geladen.', attrs='inputmode="url" spellcheck="false"') }} +
+
+ {% for link in project.links %}{{ linkRow('links', link) }}{% endfor %} +
+ + +
+
+ +
+ + Zurück zur Liste +
+
+ +{% if not isNew %} +
+ + +
+{% endif %} +{% endblock %} diff --git a/src/views/admin/projects.njk b/src/views/admin/projects.njk new file mode 100644 index 0000000..9e4b963 --- /dev/null +++ b/src/views/admin/projects.njk @@ -0,0 +1,31 @@ +{% extends "admin/layout.njk" %} +{% set title = 'Projekte' %} + +{% block content %} +
+

Projekte

+ Neues Projekt +
+ +{% if projects.length %} + +{% else %} +

Noch keine Projekte.

+{% endif %} +{% endblock %} diff --git a/test/admin.test.js b/test/admin.test.js new file mode 100644 index 0000000..a19fddd --- /dev/null +++ b/test/admin.test.js @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; +import sharp from 'sharp'; +import YAML from 'yaml'; +import { isPrivateAddress } from '../src/admin/auth.js'; +import { createAdminApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; +import { createContentStore } from '../src/content.js'; +import { seedData } from '../src/data.js'; +import { createFixture } from './fixtures.js'; + +let contentDir; +let dataDir; +let cacheDir; +let server; +let base; +let publicStore; +let cookie = ''; +let csrf = ''; + +before(async () => { + contentDir = await createFixture(); + dataDir = await mkdtemp(path.join(os.tmpdir(), 'site-data-')); + cacheDir = await mkdtemp(path.join(os.tmpdir(), 'site-cache-')); + const config = loadConfig({ + production: true, + showDrafts: false, + contentDir, + dataDir, + cacheDir, + siteUrl: 'https://example.org', + adminPassword: 'correct horse', + }); + await seedData(config); + publicStore = createContentStore(config); + await publicStore.reload(); + const drafts = createContentStore({ ...config, showDrafts: true }); + const app = await createAdminApp({ config, content: drafts, stores: [publicStore, drafts] }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + server.close(); + for (const dir of [contentDir, dataDir, cacheDir]) await rm(dir, { recursive: true, force: true }); +}); + +const request = (url, options = {}) => + fetch(base + url, { redirect: 'manual', ...options, headers: { cookie, ...options.headers } }); + +const form = (fields) => new URLSearchParams({ _csrf: csrf, ...fields }); + +const readYaml = async (rel) => YAML.parse(await readFile(path.join(dataDir, rel), 'utf8')); + +const image = (format = 'png', options = {}) => + sharp({ create: { width: 3000, height: 2000, channels: 3, background: '#ff76a4' } }) + [format]() + .withMetadata(options) + .toBuffer(); + +test('everything needs a login', async () => { + for (const url of ['/admin', '/admin/werdegang', '/de/', '/media/projects/alpha/cover.png']) { + const res = await request(url); + assert.equal(res.status, 303, url); + assert.equal(res.headers.get('location'), '/admin/login'); + } + assert.equal((await request('/admin/login')).status, 200); +}); + +test('wrong password is rejected, the right one logs in', async () => { + let res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: 'nope' }) }); + assert.equal(res.status, 401); + + res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: 'correct horse' }) }); + assert.equal(res.status, 303); + cookie = res.headers.get('set-cookie').split(';')[0]; + assert.match(res.headers.get('set-cookie'), /HttpOnly/i); + assert.match(res.headers.get('set-cookie'), /SameSite=Strict/i); + + const html = await (await request('/admin')).text(); + csrf = /name="_csrf" value="([^"]+)"/.exec(html)[1]; + assert.match(html, /Alpha/); +}); + +test('posts without the form token are refused', async () => { + const res = await request('/admin/werdegang', { method: 'POST', body: new URLSearchParams({ 'entries[0][title][de]': 'x' }) }); + assert.equal(res.status, 403); +}); + +test('create a project with images', async () => { + const body = new FormData(); + body.set('_csrf', csrf); + body.set('de[title]', 'Neues Projekt'); + body.set('de[summary]', 'Kurz'); + body.set('de[body]', 'Text mit **Markdown**'); + body.set('tags', 'Web, Test'); + body.set('featured', 'on'); + body.set('links[0][label][de]', 'Repo'); + body.set('links[0][url]', 'https://example.org/repo'); + body.set('cover', new Blob([await image('jpeg', { exif: { IFD0: { Copyright: 'secret place' } } })]), 'Foto.JPG'); + body.append('images', new Blob([await image('png')]), 'Mein Screenshot.png'); + + const res = await request('/admin/projekte/neu', { method: 'POST', body }); + assert.equal(res.status, 303); + assert.equal(res.headers.get('location'), '/admin/projekte/neues-projekt?ok=created'); + + const meta = await readYaml('projects/neues-projekt/meta.yml'); + assert.equal(meta.cover, 'cover.jpg'); + assert.deepEqual(meta.gallery, ['mein-screenshot.png']); + assert.deepEqual(meta.tags, ['web', 'test']); + assert.deepEqual(meta.links, [{ label: 'Repo', url: 'https://example.org/repo' }]); + + const cover = await sharp(path.join(dataDir, 'projects/neues-projekt/cover.jpg')).metadata(); + assert.equal(cover.width, 2400); + assert.equal(cover.exif, undefined, 'EXIF data is removed'); + + const project = publicStore.get().projects.find((p) => p.slug === 'neues-projekt'); + assert.ok(project, 'public site sees the new project right away'); + assert.equal(project.text.en.fallback, 'de'); +}); + +test('bad input is reported and nothing is written', async () => { + const body = new FormData(); + body.set('_csrf', csrf); + body.set('de[title]', 'Kaputt'); + body.set('links[0][url]', 'javascript:alert(1)'); + let res = await request('/admin/projekte/neu', { method: 'POST', body }); + assert.equal(res.status, 422); + assert.match(await res.text(), /kein gültiger Link/); + + body.set('links[0][url]', ''); + body.set('cover', new Blob(['not an image']), 'virus.exe'); + res = await request('/admin/projekte/neu', { method: 'POST', body }); + assert.equal(res.status, 422); + assert.equal(existsSync(path.join(dataDir, 'projects/kaputt')), false); +}); + +test('edit: rename, draft, remove the cover', async () => { + const body = new FormData(); + body.set('_csrf', csrf); + body.set('slug', 'umbenannt'); + body.set('de[title]', 'Neues Projekt'); + body.set('en[title]', 'New project'); + body.set('draft', 'on'); + body.set('removeCover', 'on'); + body.set('gallery[0][file]', 'mein-screenshot.png'); + body.set('gallery[0][caption][de]', 'Bildschirm'); + const res = await request('/admin/projekte/neues-projekt', { method: 'POST', body }); + assert.equal(res.status, 303); + + const dir = path.join(dataDir, 'projects/umbenannt'); + assert.equal(existsSync(path.join(dataDir, 'projects/neues-projekt')), false); + assert.equal(existsSync(path.join(dir, 'cover.jpg')), false); + assert.ok(existsSync(path.join(dir, 'en.md'))); + const meta = await readYaml('projects/umbenannt/meta.yml'); + assert.equal(meta.draft, true); + assert.deepEqual(meta.gallery, [{ src: 'mein-screenshot.png', caption: 'Bildschirm' }]); + + assert.ok(!publicStore.get().projects.some((p) => p.slug === 'umbenannt'), 'drafts stay off the public site'); + assert.equal((await request('/de/portfolio/umbenannt')).status, 200, 'but show up in the preview'); +}); + +test('delete a project', async () => { + const res = await request('/admin/projekte/umbenannt/loeschen', { method: 'POST', body: form({}) }); + assert.equal(res.status, 303); + assert.equal(existsSync(path.join(dataDir, 'projects/umbenannt')), false); + assert.equal((await request('/admin/projekte/..%2F..%2Fsite')).status, 404); +}); + +test('journey, profile and homelab', async () => { + let res = await request('/admin/werdegang', { + method: 'POST', + body: form({ + 'about[de]': 'Hallo, ich bin Test.', + 'entries[0][title][de]': 'Studium', + 'entries[0][title][en]': 'University', + 'entries[0][from]': '2024', + 'entries[0][ongoing]': 'on', + 'entries[0][type]': 'edu', + 'entries[1][title][de]': '', + }), + }); + assert.equal(res.status, 303); + assert.deepEqual((await readYaml('journey.yml')).entries, [ + { title: { de: 'Studium', en: 'University' }, type: 'edu', from: '2024', ongoing: true }, + ]); + assert.match(publicStore.get().pages.journey.de.html, /Hallo, ich bin Test/); + + res = await request('/admin/profil', { + method: 'POST', + body: form({ + name: 'Test', + email: 'neu@example.org', + 'now[0][de]': 'Tests schreiben', + 'skills[0][group][de]': 'Betrieb', + 'skills[0][items][de]': 'Linux, Videoschnitt', + 'skills[0][items][en]': 'Linux, Video editing', + }), + }); + assert.equal(res.status, 303); + const site = await readYaml('site.yml'); + assert.equal(site.email, 'neu@example.org'); + assert.deepEqual(site.skills, [{ group: 'Betrieb', items: ['Linux', { de: 'Videoschnitt', en: 'Video editing' }] }]); + + res = await request('/admin/profil', { method: 'POST', body: form({ name: '' }) }); + assert.equal(res.status, 422); + + assert.equal(publicStore.get().homelab, null); + res = await request('/admin/homelab', { + method: 'POST', + body: form({ + visible: 'on', + 'lead[de]': 'Was läuft', + 'services[0][group][de]': 'Web', + 'services[0][items][0][name]': 'nginx', + 'services[0][items][1][name]': 'Gitea', + 'services[0][items][1][url]': 'https://git.example.org', + }), + }); + assert.equal(res.status, 303); + assert.deepEqual(publicStore.get().homelab.services, [ + { group: 'Web', items: [{ name: 'nginx' }, { name: 'Gitea', url: 'https://git.example.org' }] }, + ]); + assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft'); +}); + +test('home network check', () => { + for (const ip of ['127.0.0.1', '::1', '192.168.178.20', '10.0.0.5', '172.18.0.1', '::ffff:192.168.178.20', 'fd00::1', 'fe80::1']) { + assert.ok(isPrivateAddress(ip), ip); + } + for (const ip of ['79.221.147.164', '172.32.0.1', '2003:f6:8f30:d900::1', '8.8.8.8']) { + assert.ok(!isPrivateAddress(ip), ip); + } +}); + +test('too many wrong passwords lock the login', async () => { + let res; + for (let i = 0; i < 11; i++) { + res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: `wrong ${i}` }) }); + } + assert.equal(res.status, 429); +}); diff --git a/test/app.test.js b/test/app.test.js index a1dcb3f..e4e2ff3 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -18,6 +18,7 @@ before(async () => { production: true, showDrafts: false, contentDir: dir, + dataDir: undefined, cacheDir: path.join(os.tmpdir(), `site-cache-${process.pid}`), siteUrl: 'https://example.org', }); @@ -64,7 +65,7 @@ test('the root redirects by Accept-Language', async () => { }); test('drafts, hidden pages and wrong slugs are 404', async () => { - for (const url of ['/de/portfolio/beta', '/de/homelab', '/de/journey', '/en/werdegang', '/de/nope', '/xx']) { + for (const url of ['/de/portfolio/beta', '/de/homelab', '/de/journey', '/en/werdegang', '/de/nope', '/xx', '/admin']) { const res = await get(url); assert.equal(res.status, 404, url); }