admin page

This commit is contained in:
Harlekin committed 2026-10-25 20:29:26 +01:00
1 parent 5abad7c475
commit b84349c037
33 files changed
+2500 -106

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
node_modules
dist
cache
data
test
.env
*.log
+6
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
node_modules/
dist/
cache/
data/
.env
*.log
.DS_Store
+2 -2
View File
@@ -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
+30 -41
View File
@@ -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 <name>.de.md / <name>.en.md
projects/<slug>/ ein Ordner pro Projekt
projects/<slug>/ 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://<server-ip>: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.
+612
View File
@@ -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;
}
}
+65
View File
@@ -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, '');
});
}
+7 -1
View File
@@ -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://<server-ip>: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:
+3 -3
View File
@@ -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.
+3 -3
View File
@@ -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.
+87
View File
@@ -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",
+1
View File
@@ -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"
+2
View File
@@ -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,
+86
View File
@@ -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 };
}
+200
View File
@@ -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 };
}
+254
View File
@@ -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) })),
})),
};
}
+246
View File
@@ -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;
}
+32 -11
View File
@@ -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;
}
+4
View File
@@ -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,
};
}
+48 -31
View File
@@ -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 <name>.de.md / <name>.en.md and fills a missing language from the other one.
async localizedMarkdown(baseDir, name) {
// Reads pages/<name>.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);
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);
},
};
+30
View File
@@ -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;
}
+14 -7
View File
@@ -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;
for (const root of roots) {
const candidate = resolveMedia(root, rel);
if (!candidate) continue;
try {
info = await stat(file);
info = await stat(candidate);
} catch {
return next();
continue;
}
if (!info.isFile()) return next();
if (info.isFile()) {
file = candidate;
break;
}
}
if (!file) return next();
// URLs built by the content loader carry ?v=<mtime>, those can be cached for good
const versioned = 'v' in req.query;
+34 -6
View File
@@ -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, () => {
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);
+49
View File
@@ -0,0 +1,49 @@
{% extends "admin/layout.njk" %}
{% from "admin/macros.njk" import check, pair, machineRow, serviceGroupRow %}
{% set title = 'Homelab' %}
{% block content %}
<div class="heading">
<h1>Homelab</h1>
<a class="link" href="/de/homelab">Vorschau</a>
</div>
<form class="form" method="post" action="/admin/homelab">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel">
<h2 class="panel__title">Seite</h2>
<div class="checks">
{{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }}
</div>
{{ 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.') }}
</section>
<section class="panel">
<h2 class="panel__title">Hardware</h2>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for machine in form.hardware %}{{ machineRow(machine) }}{% endfor %}
</div>
<template data-repeat-template>{{ machineRow({}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Gerät hinzufügen</button>
</div>
</section>
<section class="panel">
<h2 class="panel__title">Dienste</h2>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for group in form.services %}{{ serviceGroupRow(group) }}{% endfor %}
</div>
<template data-repeat-template>{{ serviceGroupRow({}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Gruppe hinzufügen</button>
</div>
</section>
<div class="actions">
<button class="button" type="submit">Speichern</button>
</div>
</form>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "admin/layout.njk" %}
{% from "admin/macros.njk" import pair, entryRow %}
{% set title = 'Werdegang' %}
{% block content %}
<div class="heading">
<h1>Werdegang</h1>
<a class="link" href="/de/werdegang">Vorschau</a>
</div>
<form class="form" method="post" action="/admin/werdegang">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel">
<h2 class="panel__title">Über mich</h2>
{{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional.') }}
</section>
<section class="panel">
<h2 class="panel__title">Stationen</h2>
<p class="panel__hint">Neueste zuerst. Die Reihenfolge änderst du mit „Hoch“ und „Runter“.</p>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for entry in form.entries %}{{ entryRow(entry) }}{% endfor %}
</div>
<template data-repeat-template>{{ entryRow({}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Station hinzufügen</button>
</div>
</section>
<div class="actions">
<button class="button" type="submit">Speichern</button>
</div>
</form>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% if title %}{{ title }} · {% endif %}Admin</title>
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="{{ asset('admin.css') }}">
<script type="module" src="{{ asset('admin.js') }}"></script>
</head>
<body class="admin">
{% if loggedIn %}
<header class="bar">
<div class="bar__inner">
<a class="bar__brand" href="/admin"><img src="/avatar.png" width="32" height="32" alt=""> Admin</a>
<nav class="bar__nav" aria-label="Bereiche">
{% for id, href, label in [['projects', '/admin', 'Projekte'], ['journey', '/admin/werdegang', 'Werdegang'], ['profile', '/admin/profil', 'Profil'], ['homelab', '/admin/homelab', 'Homelab']] %}
<a href="{{ href }}" {% if section == id %}aria-current="page"{% endif %}>{{ label }}</a>
{% endfor %}
</nav>
<div class="bar__end">
<a class="link" href="/de/">Vorschau</a>
<a class="link" href="{{ siteUrl }}/de/">Live</a>
<form method="post" action="/admin/logout">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<button class="link" type="submit">Abmelden</button>
</form>
</div>
</div>
</header>
{% endif %}
<main class="main">
<noscript><p class="notice notice--error">Die Admin-Seiten brauchen JavaScript.</p></noscript>
{% if notice %}<p class="notice notice--ok" role="status">{{ notice }}</p>{% endif %}
{% if error %}<p class="notice notice--error" role="alert">{{ error }}</p>{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{% extends "admin/layout.njk" %}
{% set title = 'Anmelden' %}
{% block content %}
<div class="login">
<img class="login__avatar" src="/avatar.png" width="88" height="88" alt="">
<h1 class="login__title">Admin</h1>
<form class="login__form" method="post" action="/admin/login">
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" name="password" autocomplete="current-password" required autofocus>
</label>
<button class="button" type="submit">Anmelden</button>
</form>
</div>
{% endblock %}
+144
View File
@@ -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='') -%}
<label class="field">
<span class="field__label">{{ label }}</span>
<input class="input" type="{{ type }}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %} value="{{ value }}" {{ attrs | safe }}>
{% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %}
</label>
{%- endmacro %}
{% macro area(label, name, value='', rows=3, hint='', mono=false) -%}
<label class="field">
<span class="field__label">{{ label }}</span>
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %} rows="{{ rows }}">{{ value }}</textarea>
{% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %}
</label>
{%- endmacro %}
{% macro check(label, name, checked=false) -%}
<label class="check">
<input type="checkbox" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %}{% if checked %} checked{% endif %}>
<span>{{ label }}</span>
</label>
{%- endmacro %}
{% macro select(label, name, value, options) -%}
<label class="field">
<span class="field__label">{{ label }}</span>
<select class="input" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %}>
{% for optionValue, optionLabel in options %}
<option value="{{ optionValue }}"{% if optionValue == value %} selected{% endif %}>{{ optionLabel }}</option>
{% endfor %}
</select>
</label>
{%- endmacro %}
{# Same field in German and English side by side #}
{% macro pair(label, name, value, rows=0, hint='', mono=false) -%}
<fieldset class="pair">
<legend class="field__label">{{ label }}</legend>
<div class="pair__fields">
{% for lang in ['de', 'en'] %}
{% set full = name + '[' + lang + ']' %}
<label class="pair__field">
<span class="pair__lang">{{ lang | upper }}</span>
{% if rows %}
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} rows="{{ rows }}">{{ value[lang] }}</textarea>
{% else %}
<input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}">
{% endif %}
</label>
{% endfor %}
</div>
{% if hint %}<p class="field__hint">{{ hint }}</p>{% endif %}
</fieldset>
{%- endmacro %}
{% macro rowTools() -%}
<div class="row__tools">
<button type="button" class="tool" data-repeat-up>Hoch</button>
<button type="button" class="tool" data-repeat-down>Runter</button>
<button type="button" class="tool tool--danger" data-repeat-remove>Entfernen</button>
</div>
{%- endmacro %}
{% macro linkRow(prefix, link) -%}
<div class="row" data-repeat-row>
<div class="grid grid--3">
{{ 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') }}
</div>
{{ rowTools() }}
</div>
{%- endmacro %}
{% macro entryRow(entry) -%}
<fieldset class="row" data-repeat-row>
{{ pair('Titel', 'entries[#][title]', entry.title) }}
<div class="grid grid--3">
{{ 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']]) }}
</div>
<div class="checks">
{{ check('Läuft noch (wird als „seit …“ angezeigt)', 'entries[#][ongoing]', entry.ongoing) }}
{{ check('Entwurf', 'entries[#][draft]', entry.draft) }}
</div>
{{ pair('Ort', 'entries[#][place]', entry.place) }}
{{ pair('Beschreibung', 'entries[#][text]', entry.text, rows=3) }}
{{ rowTools() }}
</fieldset>
{%- endmacro %}
{% macro nowRow(item) -%}
<div class="row row--inline" data-repeat-row>
{{ pair('Eintrag', 'now[#]', item) }}
{{ rowTools() }}
</div>
{%- endmacro %}
{% macro skillRow(group) -%}
<fieldset class="row" data-repeat-row>
{{ 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() }}
</fieldset>
{%- endmacro %}
{% macro machineRow(machine) -%}
<fieldset class="row" data-repeat-row>
<div class="grid grid--2">
{{ field('Name', 'hardware[#][name]', machine.name) }}
</div>
{{ 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() }}
</fieldset>
{%- endmacro %}
{% macro serviceRow(item) -%}
<div class="row row--nested" data-repeat-row>
<div class="grid grid--2">
{{ field('Name', 'services[#][items][#][name]', item.name) }}
{{ field('Link (optional)', 'services[#][items][#][url]', item.url) }}
</div>
{{ pair('Beschreibung', 'services[#][items][#][text]', item.text) }}
{{ rowTools() }}
</div>
{%- endmacro %}
{% macro serviceGroupRow(group) -%}
<fieldset class="row" data-repeat-row>
{{ pair('Gruppe', 'services[#][group]', group.group) }}
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for item in group.items %}{{ serviceRow(item) }}{% endfor %}
</div>
<template data-repeat-template>{{ serviceRow({}) }}</template>
<button type="button" class="button button--ghost button--small" data-repeat-add>Dienst hinzufügen</button>
</div>
{{ rowTools() }}
</fieldset>
{%- endmacro %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "admin/layout.njk" %}
{% from "admin/macros.njk" import field, pair, linkRow, nowRow, skillRow %}
{% set title = 'Profil' %}
{% block content %}
<div class="heading">
<h1>Profil</h1>
<a class="link" href="/de/">Vorschau</a>
</div>
<form class="form" method="post" action="/admin/profil">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel">
<h2 class="panel__title">Allgemein</h2>
<div class="grid grid--2">
{{ 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.') }}
</div>
{{ pair('Pronomen', 'pronouns', form.pronouns) }}
{{ pair('Beschreibung', 'description', form.description, rows=2, hint='Für Suchmaschinen und Link-Vorschauen.') }}
</section>
<section class="panel">
<h2 class="panel__title">Gerade dran</h2>
<p class="panel__hint">Die kleine Box auf der Startseite. Ohne Einträge wird sie ausgeblendet.</p>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for item in form.now %}{{ nowRow(item) }}{% endfor %}
</div>
<template data-repeat-template>{{ nowRow({}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Eintrag hinzufügen</button>
</div>
</section>
<section class="panel">
<h2 class="panel__title">Links</h2>
<p class="panel__hint">Profile woanders, z. B. Git, LinkedIn. Erscheinen auf der Kontaktseite und im Footer.</p>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for link in form.links %}{{ linkRow('links', link) }}{% endfor %}
</div>
<template data-repeat-template>{{ linkRow('links', {}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Link hinzufügen</button>
</div>
</section>
<section class="panel">
<h2 class="panel__title">Kenntnisse</h2>
<p class="panel__hint">Erscheinen auf der Werdegang-Seite und im Laufband auf der Startseite.</p>
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for group in form.skills %}{{ skillRow(group) }}{% endfor %}
</div>
<template data-repeat-template>{{ skillRow({}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Gruppe hinzufügen</button>
</div>
</section>
<div class="actions">
<button class="button" type="submit">Speichern</button>
</div>
</form>
{% endblock %}
+104
View File
@@ -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 %}
<div class="heading">
<h1>{{ title }}</h1>
{% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %}
</div>
<form class="form" method="post" action="{{ '/admin/projekte/neu' if isNew else '/admin/projekte/' + savedSlug }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel">
<h2 class="panel__title">Allgemein</h2>
<div class="grid grid--3">
{{ 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') }}
</div>
<div class="checks">
{{ check('Entwurf (nur in der Vorschau sichtbar)', 'draft', project.draft) }}
{{ check('Auf der Startseite zeigen', 'featured', project.featured) }}
</div>
</section>
<section class="panel">
<h2 class="panel__title">Texte</h2>
<p class="panel__hint">Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder aus der Galerie bindest du mit <code>![Beschreibung](dateiname.jpg)</code> ein.</p>
<div class="langs">
{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}
<div class="lang">
<h3 class="lang__title">{{ label }}</h3>
{{ 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) }}
</div>
{% endfor %}
</div>
</section>
<section class="panel">
<h2 class="panel__title">Bilder</h2>
<div class="cover">
<div class="cover__preview">
{% if coverUrl %}<img src="{{ coverUrl }}" alt="">{% else %}<span>Kein Cover</span>{% endif %}
</div>
<div class="cover__fields">
<label class="field">
<span class="field__label">{{ 'Cover ersetzen' if coverUrl else 'Cover hochladen' }}</span>
<input class="input" type="file" name="cover" accept="image/jpeg,image/png,image/webp,image/gif,image/avif">
</label>
{% if coverUrl %}{{ check('Cover entfernen', 'removeCover') }}{% endif %}
</div>
</div>
<h3 class="panel__subtitle">Galerie</h3>
{% if gallery.length %}
<ul class="gallery" role="list">
{% for item in gallery %}
{% set i = loop.index0 %}
<li class="gallery__item">
<img class="gallery__img" src="{{ item.url }}" alt="" loading="lazy">
<input type="hidden" name="gallery[{{ i }}][file]" value="{{ item.file }}">
<code class="gallery__name">{{ item.file }}</code>
{{ field('Bildunterschrift', 'gallery[' + i + '][caption][de]', item.caption.de) }}
{{ field('Bildunterschrift EN', 'gallery[' + i + '][caption][en]', item.caption.en) }}
{{ check('Löschen', 'gallery[' + i + '][remove]') }}
</li>
{% endfor %}
</ul>
{% endif %}
<label class="field">
<span class="field__label">Bilder hinzufügen</span>
<input class="input" type="file" name="images" accept="image/jpeg,image/png,image/webp,image/gif,image/avif" multiple>
<span class="field__hint">JPG, PNG, WebP, GIF oder AVIF, bis 25 MB pro Bild. Große Bilder werden auf 2400 px verkleinert, Kameradaten wie der Aufnahmeort werden entfernt.</span>
</label>
</section>
<section class="panel">
<h2 class="panel__title">Links und Video</h2>
{{ field('YouTube-Link', 'video', project.video, hint='Wird auf der Seite erst nach einem Klick geladen.', attrs='inputmode="url" spellcheck="false"') }}
<div class="repeat" data-repeat>
<div class="repeat__list" data-repeat-list>
{% for link in project.links %}{{ linkRow('links', link) }}{% endfor %}
</div>
<template data-repeat-template>{{ linkRow('links', {}) }}</template>
<button type="button" class="button button--ghost" data-repeat-add>Link hinzufügen</button>
</div>
</section>
<div class="actions">
<button class="button" type="submit">Speichern</button>
<a class="button button--ghost" href="/admin">Zurück zur Liste</a>
</div>
</form>
{% if not isNew %}
<form class="danger" method="post" action="/admin/projekte/{{ savedSlug }}/loeschen" data-confirm="„{{ title }}“ wirklich löschen? Das lässt sich nicht rückgängig machen.">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<button class="button button--danger" type="submit">Projekt löschen</button>
</form>
{% endif %}
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "admin/layout.njk" %}
{% set title = 'Projekte' %}
{% block content %}
<div class="heading">
<h1>Projekte</h1>
<a class="button" href="/admin/projekte/neu">Neues Projekt</a>
</div>
{% if projects.length %}
<ul class="projects" role="list">
{% for project in projects %}
<li>
<a class="project" href="/admin/projekte/{{ project.slug }}">
<span class="project__thumb">{% if project.thumb %}<img src="{{ project.thumb }}" alt="" loading="lazy">{% endif %}</span>
<span class="project__text">
<span class="project__title">{{ project.text.de.title }}</span>
<span class="project__meta">{{ project.date }}{% if project.tags.length %} · {{ project.tags | join(', ') }}{% endif %}</span>
</span>
<span class="project__badges">
{% if project.draft %}<span class="badge badge--draft">Entwurf</span>{% endif %}
{% if project.featured %}<span class="badge">Startseite</span>{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty">Noch keine Projekte.</p>
{% endif %}
{% endblock %}
+247
View File
@@ -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);
});
+2 -1
View File
@@ -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);
}