diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3832668 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +data +node_modules +npm-debug.log +README.md +k8s +Einladung.pdf diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml new file mode 100644 index 0000000..e0a4533 --- /dev/null +++ b/.gitea/workflows/container.yml @@ -0,0 +1,123 @@ +name: Publish and deploy birthday invitation + +on: + push: + +env: + IMAGE: registry.brunner.ninja/feedc0de/30-birthday + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + +jobs: + test: + name: Check JavaScript + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + - name: Check server and browser scripts + run: | + docker run --rm --volume "$PWD:/src:ro" --workdir /src node:24-alpine node --check server.js + docker run --rm --volume "$PWD:/src:ro" --workdir /src node:24-alpine node --check database.js + docker run --rm --volume "$PWD:/src:ro" --workdir /src node:24-alpine node --check public/app.js + docker run --rm --volume "$PWD:/src:ro" --workdir /src node:24-alpine node --check public/admin.js + + publish: + name: Build and push + needs: test + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Log in to Quay + uses: docker/login-action@v3 + with: + registry: registry.brunner.ninja + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_TOKEN }} + + - name: Build image + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + docker build \ + --load \ + --label "org.opencontainers.image.revision=${COMMIT_SHA}" \ + --label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/30-birthday" \ + --tag "${IMAGE}:${COMMIT_SHA}" \ + . + + - name: Push commit tag + env: + COMMIT_SHA: ${{ gitea.sha }} + run: docker push "${IMAGE}:${COMMIT_SHA}" + + - name: Push latest tag + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest" + docker push "${IMAGE}:latest" + + deploy: + name: Deploy to Kubernetes + if: gitea.ref == 'refs/heads/main' + needs: publish + runs-on: ubuntu-latest + steps: + - name: Install kubectl + env: + KUBECTL_VERSION: v1.36.4 + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster \ + --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + + - name: Deploy commit image + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" --namespace default set image \ + deployment/30-birthday \ + "30-birthday=${IMAGE}:${COMMIT_SHA}" + + for attempt in {1..60}; do + IFS='|' read -r generation observed desired updated available unavailable <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace default get deployment/30-birthday \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}' + )" + + echo "Rollout ${attempt}/60: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available" + if [[ "${observed}" == "${generation}" \ + && "${updated}" == "${desired}" \ + && "${available}" == "${desired}" \ + && ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then + echo "Deployment successfully rolled out" + exit 0 + fi + + sleep 5 + done + + "${RUNNER_TEMP}/kubectl" --namespace default describe deployment/30-birthday + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bd1fbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/invites.json +data/invites.db* +.env +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4cb533 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:24-alpine + +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 \ + DATABASE_FILE=/data/invites.db + +WORKDIR /app +RUN mkdir -p /data && chown 65532:65532 /data +COPY --chown=65532:65532 package.json server.js database.js ./ +COPY --chown=65532:65532 public ./public +COPY --chown=65532:65532 scripts ./scripts + +# Root is convenient with rootless Podman (it maps to the invoking host user). +# Kubernetes overrides this to the unprivileged UID/GID 65532. +USER 0:0 +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/Einladung.pdf b/Einladung.pdf new file mode 100644 index 0000000..1a44b92 Binary files /dev/null and b/Einladung.pdf differ diff --git a/README.md b/README.md index d3c284e..d6c4d91 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,121 @@ -# 30-birthday +# Einladung & RSVP -Geburtstagskarte zum 30. Geburtstag mit Zu- oder Absagendatenbank \ No newline at end of file +Eine responsive Einladung für Vicky und Daniels 30. Geburtstag. Jeder Gast erhält einen persönlichen Link, kann die Originaleinladung als PDF öffnen und eine von drei Antworten abgeben: + +- Ich komme nicht +- Ich komme alleine +- Ich komme mit jemandem (inklusive Name der Begleitung) + +Der Server benötigt nur Node.js 22.5 oder neuer und keine zusätzlichen Pakete. Als Datenbank dient SQLite über Nodes eingebautes `node:sqlite`-Modul. + +## Lokal starten + +```bash +ADMIN_KEY='ein-langes-geheimes-passwort' \ +ORIGIN='http://localhost:3000' \ +npm start +``` + +Danach läuft die Seite unter `http://localhost:3000`. Die Verwaltung ist unter `http://localhost:3000/admin` erreichbar. Ohne `ADMIN_KEY` oder vorgeschaltetes Authentik bleiben die Admin-Endpunkte aus Sicherheitsgründen deaktiviert. + +## Viele Links erstellen und Antworten ansehen + +Öffne `/admin`, melde dich mit dem `ADMIN_KEY` an und füge bis zu 200 Namen auf einmal ein – einen Namen pro Zeile. Die erzeugten Links können einzeln oder gesammelt kopiert werden. + +Die Übersicht zeigt: + +- ob und wann ein Link erstmals geöffnet wurde +- wann er zuletzt geöffnet wurde und wie oft +- die aktuelle Zu- oder Absage +- den Namen einer Begleitperson +- wann die Antwort zuletzt gespeichert beziehungsweise geändert wurde +- die erwartete Gesamtzahl der kommenden Gäste + +Über „CSV herunterladen“ lässt sich die vollständige Liste für Excel, Numbers oder LibreOffice exportieren. Eine Öffnung wird gezählt, sobald das JavaScript der RSVP-Seite die Einladung abruft. Manche besonders aktive Link-Vorschau-Bots können theoretisch ebenfalls eine Öffnung erzeugen. + +## Persönlichen Einladungslink anlegen + +Am einfachsten direkt auf dem Server: + +```bash +ORIGIN='https://30.brunner.ninja' npm run invite -- "Max Mustermann" +``` + +Die Ausgabe enthält den fertigen Link, zum Beispiel: + +```text +Max Mustermann: https://30.brunner.ninja/i/zufaelliger-sicherer-token +``` + +Alternativ können mehrere Links über die Admin-API erzeugt werden: + +```bash +curl -X POST 'https://30.brunner.ninja/api/admin/invites' \ + -H 'Authorization: Bearer DEIN_ADMIN_KEY' \ + -H 'Content-Type: application/json' \ + --data '{"names":["Max Mustermann","Erika Musterfrau"]}' +``` + +## Antworten exportieren + +```bash +curl 'https://30.brunner.ninja/api/admin/responses.csv' \ + -H 'Authorization: Bearer DEIN_ADMIN_KEY' \ + -o zusagen.csv +``` + +Die CSV-Datei lässt sich direkt mit Excel, Numbers oder LibreOffice öffnen. + +## Daten und Betrieb + +Antworten werden in `data/invites.db` gespeichert. SQLite läuft im WAL-Modus; das Verzeichnis muss im Produktionsbetrieb beschreibbar und persistent sein. Regelmäßige Backups der Datenbank werden empfohlen. + +Für die öffentliche Domain sollte die App hinter einem HTTPS-Reverse-Proxy (beispielsweise Caddy oder nginx) laufen. Benötigte Umgebungsvariablen: + +| Variable | Zweck | Standard | +| --- | --- | --- | +| `PORT` | Interner HTTP-Port | `3000` | +| `HOST` | Listen-Adresse | `0.0.0.0` | +| `ORIGIN` | Öffentliche Basis-URL für erzeugte Links | `http://localhost:3000` | +| `DATABASE_FILE` | Pfad zur SQLite-Datenbank | `data/invites.db` | +| `ADMIN_KEY` | Geheimer Schlüssel für lokalen Admin-Zugriff | keiner | +| `TRUST_AUTHENTIK` | Akzeptiert Authentiks verifiziertes Benutzer-Header (`true`) | `false` | + +Die PDF-Datei ist über `/Einladung.pdf` erreichbar. Die RSVP-Seiten liegen unter `/i/`. + +## Kubernetes, Traefik und Authentik + +Ein vollständiges Beispiel liegt in `k8s/invitation.yaml`. Es verwendet: + +- genau einen Pod (SQLite ist hier bewusst nicht horizontal skaliert) +- einen `128Mi`-`rook-ceph-block`-PVC für `/data/invites.db` +- die Domain `30.brunner.ninja` +- ausschließlich Traefiks EntryPoint `websecure` +- das bestehende `default-authentik@kubernetescrd`-Middleware nur für `/admin*` und `/api/admin/*` +- öffentliche Gast- und Asset-Routen ohne Authentik +- `Recreate`-Updates mit einer kurzen Unterbrechung, damit nur ein SQLite-Prozess den Block-PVC verwendet + +Zuerst das Image bauen und in die eigene Registry pushen: + +```bash +docker build -t registry.brunner.ninja/feedc0de/30-birthday:latest . +docker push registry.brunner.ninja/feedc0de/30-birthday:latest +``` + +Die Gitea Action in `.gitea/workflows/container.yml` übernimmt Build, Push und Deployment automatisch. Sie benötigt diese Repository-Secrets: + +- `QUAY_USERNAME`: `feedc0de+birthday30` +- `QUAY_TOKEN`: Token dieses Robot-Accounts +- `KUBE_CONFIG_BASE64`: Kubeconfig des eingeschränkten `30-birthday-deployer`-ServiceAccounts + +Workload und CI-Berechtigungen werden einmalig mit einem Cluster-Administrator angelegt: + +```bash +kubectl apply -f k8s/invitation.yaml +kubectl apply -f k8s/ci-deployer.yaml +./k8s/create-ci-kubeconfig.sh +``` + +Die Action baut `registry.brunner.ninja/feedc0de/30-birthday`, versieht jedes Image mit dem unveränderlichen Commit-SHA und aktualisiert auf `main` ausschließlich das bestehende `default/30-birthday`-Deployment. Ihre Kubernetes-Rolle kann keine Secrets oder anderen Deployments lesen oder verändern. + +Der Cluster stellt Authentiks `/outpost.goauthentik.io/*` bereits zentral bereit. Der App-Service ist nur als `ClusterIP` erreichbar; daher kann ein externer Client Authentiks Identitätsheader nicht direkt am App-Port einschleusen. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/database.js b/database.js new file mode 100644 index 0000000..defa4b3 --- /dev/null +++ b/database.js @@ -0,0 +1,122 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { DatabaseSync } = require("node:sqlite"); + +const DATABASE_FILE = process.env.DATABASE_FILE || process.env.DATA_FILE || path.join(__dirname, "data", "invites.db"); +fs.mkdirSync(path.dirname(DATABASE_FILE), { recursive: true }); + +const database = new DatabaseSync(DATABASE_FILE); +database.exec("PRAGMA journal_mode = WAL"); +database.exec("PRAGMA busy_timeout = 5000"); +database.exec("PRAGMA foreign_keys = ON"); +database.exec(` + CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, + guest_name TEXT NOT NULL, + answer TEXT CHECK (answer IN ('not_coming', 'alone', 'with_someone')), + companion_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + first_opened_at TEXT, + last_opened_at TEXT, + open_count INTEGER NOT NULL DEFAULT 0, + responded_at TEXT + ) STRICT +`); + +const selectColumns = ` + token, + guest_name AS guestName, + answer, + companion_name AS companionName, + created_at AS createdAt, + first_opened_at AS firstOpenedAt, + last_opened_at AS lastOpenedAt, + open_count AS openCount, + responded_at AS respondedAt +`; + +const selectOne = database.prepare(`SELECT ${selectColumns} FROM invites WHERE token = ?`); +const selectAll = database.prepare(`SELECT ${selectColumns} FROM invites ORDER BY created_at ASC`); +const insertOne = database.prepare(` + INSERT INTO invites (token, guest_name, created_at) + VALUES (?, ?, ?) +`); + +function migrateLegacyJson() { + if (process.env.DATABASE_FILE || process.env.DATA_FILE) return; + const legacyFile = path.join(__dirname, "data", "invites.json"); + const count = database.prepare("SELECT COUNT(*) AS count FROM invites").get().count; + if (count || !fs.existsSync(legacyFile)) return; + try { + const legacy = JSON.parse(fs.readFileSync(legacyFile, "utf8")); + const insertLegacy = database.prepare(` + INSERT OR IGNORE INTO invites ( + token, guest_name, answer, companion_name, created_at, + first_opened_at, last_opened_at, open_count, responded_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + database.exec("BEGIN IMMEDIATE"); + for (const invite of legacy.invites || []) { + insertLegacy.run( + invite.token, + invite.guestName || "", + invite.answer || null, + invite.companionName || "", + invite.createdAt || new Date().toISOString(), + invite.firstOpenedAt || null, + invite.lastOpenedAt || null, + Number(invite.openCount || 0), + invite.respondedAt || null + ); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch {} + console.warn("Legacy JSON data could not be imported:", error.message); + } +} + +migrateLegacyJson(); + +function getInvite(token) { + return selectOne.get(token) || null; +} + +function listInvites() { + return selectAll.all(); +} + +function recordOpen(token, openedAt) { + const result = database.prepare(` + UPDATE invites + SET first_opened_at = COALESCE(first_opened_at, ?), + last_opened_at = ?, + open_count = open_count + 1 + WHERE token = ? + `).run(openedAt, openedAt, token); + return result.changes ? getInvite(token) : null; +} + +function saveRsvp(token, answer, companionName, respondedAt) { + const result = database.prepare(` + UPDATE invites + SET answer = ?, companion_name = ?, responded_at = ? + WHERE token = ? + `).run(answer, companionName, respondedAt, token); + return result.changes ? getInvite(token) : null; +} + +function createInvites(entries) { + const createdAt = new Date().toISOString(); + database.exec("BEGIN IMMEDIATE"); + try { + for (const entry of entries) insertOne.run(entry.token, entry.guestName, createdAt); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + return entries.map(entry => getInvite(entry.token)); +} + +module.exports = { DATABASE_FILE, createInvites, getInvite, listInvites, recordOpen, saveRsvp }; diff --git a/k8s/ci-deployer.yaml b/k8s/ci-deployer.yaml new file mode 100644 index 0000000..5a38e4a --- /dev/null +++ b/k8s/ci-deployer.yaml @@ -0,0 +1,49 @@ +# Bootstrap once with an administrator identity. CI can update only the +# existing 30-birthday Deployment and cannot access Kubernetes Secrets. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: 30-birthday-deployer + namespace: default +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: 30-birthday-deployer + namespace: default +rules: + - apiGroups: + - apps + resources: + - deployments + resourceNames: + - 30-birthday + verbs: + - get + - patch + - update + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: 30-birthday-deployer + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: 30-birthday-deployer +subjects: + - kind: ServiceAccount + name: 30-birthday-deployer + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: 30-birthday-deployer-token + namespace: default + annotations: + kubernetes.io/service-account.name: 30-birthday-deployer +type: kubernetes.io/service-account-token diff --git a/k8s/create-ci-kubeconfig.sh b/k8s/create-ci-kubeconfig.sh new file mode 100755 index 0000000..3af53d6 --- /dev/null +++ b/k8s/create-ci-kubeconfig.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +namespace=default +secret=30-birthday-deployer-token +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +server=$(kubectl config view --minify --output 'jsonpath={.clusters[0].cluster.server}') +kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.ca\.crt}' \ + | base64 --decode > "${workdir}/ca.crt" +token=$(kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.token}' \ + | base64 --decode) + +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster --server "${server}" --certificate-authority "${workdir}/ca.crt" --embed-certs=true \ + > /dev/null +kubectl config set-credentials 30-birthday-deployer --token "${token}" > /dev/null +kubectl config set-context 30-birthday --cluster cluster --user 30-birthday-deployer --namespace "${namespace}" \ + > /dev/null +kubectl config use-context 30-birthday > /dev/null + +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/k8s/invitation.yaml b/k8s/invitation.yaml new file mode 100644 index 0000000..349a8d6 --- /dev/null +++ b/k8s/invitation.yaml @@ -0,0 +1,161 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: 30-birthday-data + namespace: default +spec: + accessModes: + - ReadWriteOnce + storageClassName: rook-ceph-block + resources: + requests: + storage: 128Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: 30-birthday + namespace: default +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: 30-birthday + template: + metadata: + labels: + app: 30-birthday + spec: + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: 30-birthday + image: registry.brunner.ninja/feedc0de/30-birthday:latest + imagePullPolicy: Always + env: + - name: ORIGIN + value: https://30.brunner.ninja + - name: DATABASE_FILE + value: /data/invites.db + - name: TRUST_AUTHENTIK + value: "true" + ports: + - name: http + containerPort: 3000 + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: + cpu: 10m + memory: 32Mi + ephemeral-storage: 32Mi + limits: + cpu: 250m + memory: 192Mi + ephemeral-storage: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + volumes: + - name: data + persistentVolumeClaim: + claimName: 30-birthday-data +--- +apiVersion: v1 +kind: Service +metadata: + name: 30-birthday + namespace: default +spec: + selector: + app: 30-birthday + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http +--- +# Guest pages, invitation assets, and guest RSVP APIs stay public. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: 30-birthday-public + namespace: default + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.priority: "10" +spec: + ingressClassName: traefik + tls: + - hosts: + - 30.brunner.ninja + secretName: brunner-ninja-tls + rules: + - host: 30.brunner.ninja + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: 30-birthday + port: + name: http +--- +# Longer admin prefixes win over the public catch-all and require Authentik. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: 30-birthday-admin + namespace: default + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.middlewares: default-authentik@kubernetescrd + traefik.ingress.kubernetes.io/router.priority: "100" +spec: + ingressClassName: traefik + tls: + - hosts: + - 30.brunner.ninja + secretName: brunner-ninja-tls + rules: + - host: 30.brunner.ninja + http: + paths: + - path: /admin + pathType: Prefix + backend: + service: + name: 30-birthday + port: + name: http + - path: /api/admin/ + pathType: Prefix + backend: + service: + name: 30-birthday + port: + name: http diff --git a/package.json b/package.json new file mode 100644 index 0000000..5554de0 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "vicky-daniel-rsvp", + "version": "1.0.0", + "private": true, + "description": "Responsive invitation and token-based RSVP page", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "invite": "node scripts/create-invite.js", + "test": "node --test" + }, + "engines": { + "node": ">=22.5" + } +} diff --git a/public/Einladung.pdf b/public/Einladung.pdf new file mode 100644 index 0000000..1a44b92 Binary files /dev/null and b/public/Einladung.pdf differ diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..3b45583 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,55 @@ +.admin-body { background-attachment: fixed; font-family: system-ui, sans-serif; } +.admin-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 48px 0 80px; } +.admin-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 30px; } +.admin-header .hello { font-family: "Segoe Script", "Bradley Hand", cursive; } +.admin-header h1 { margin: 0; color: var(--ink); font: 400 clamp(2.4rem, 6vw, 4rem)/1 Georgia, serif; } +.header-actions, .created-actions { display: flex; gap: .6rem; } +.admin-card { padding: 30px; background: rgba(255,253,249,.94); border: 1px solid rgba(0,104,128,.1); border-radius: 22px; box-shadow: 0 16px 45px rgba(15,78,88,.1); } +.admin-card h2 { font-size: 1.8rem; } +.admin-card label { display: block; margin: 1.2rem 0 .4rem; color: var(--ink-dark); font-weight: 700; } +.admin-card input, .admin-card textarea { width: 100%; padding: .85rem 1rem; color: var(--ink-dark); background: white; border: 1.5px solid var(--line); border-radius: 12px; outline: none; resize: vertical; } +.admin-card input:focus, .admin-card textarea:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24,185,187,.15); } +.login-card { width: min(480px, 100%); margin: 10vh auto 0; text-align: center; } +.login-card .state-icon { margin-bottom: 1rem; } +.login-card label { text-align: left; } +.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; } +.stats div { padding: 22px; background: var(--ink); border-radius: 18px; box-shadow: 0 10px 30px rgba(0,104,128,.12); } +.stats div:nth-child(2) { background: #008ea0; } +.stats div:nth-child(3) { background: var(--turquoise); } +.stats div:nth-child(4) { background: var(--mint); } +.stats strong, .stats span { display: block; color: white; } +.stats strong { font: 400 2.2rem/1 Georgia, serif; } +.stats span { margin-top: .4rem; font-size: .74rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.admin-grid { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr); gap: 22px; margin-bottom: 22px; } +.admin-card .eyebrow { margin: 0 0 .7rem; } +.admin-card p { color: #56767d; } +button.small { width: auto; margin: 0; padding: .7rem 1rem; font-size: .78rem; } +.links-card { min-height: 380px; } +.empty { padding: 2.5rem 1rem; color: #789096; text-align: center; } +#created-list { overflow: auto; max-height: 270px; margin: 1rem 0 0; padding: 0; list-style: none; } +#created-list li { display: grid; grid-template-columns: minmax(100px, .7fr) minmax(180px, 1.3fr) auto; gap: 10px; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--line); } +#created-list a { overflow: hidden; color: var(--ink); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; } +#created-list button { width: auto; margin: 0; padding: .45rem .7rem; font-size: .7rem; } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; } +.tracking-note { margin: .8rem 0 1.2rem; font-size: .78rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .82rem; white-space: nowrap; } +th, td { padding: .85rem .7rem; border-bottom: 1px solid var(--line); text-align: left; } +th { color: #68848a; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; } +td:first-child { color: var(--ink-dark); font-weight: 700; } +.status { display: inline-block; padding: .32rem .55rem; color: #5e7479; background: #edf2f1; border-radius: 999px; font-size: .69rem; font-weight: 700; } +.status.alone, .status.with_someone { color: #167039; background: #e6f7e8; } +.status.not_coming { color: #9a462d; background: #fcedea; } +.link-button { width: auto; margin: 0; padding: .42rem .65rem; font-size: .68rem; } +code { padding: .1em .3em; background: #edf5f3; border-radius: 4px; } + +@media (max-width: 760px) { + .admin-shell { width: min(100% - 24px, 620px); padding-top: 24px; } + .admin-header { align-items: flex-start; } + .stats { grid-template-columns: 1fr 1fr; } + .admin-grid { grid-template-columns: 1fr; } + .admin-card { padding: 22px 18px; } + .section-heading { align-items: flex-start; flex-direction: column; } + #created-list li { grid-template-columns: 1fr auto; } + #created-list a { grid-column: 1 / -1; } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..0d479b3 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,102 @@ + + + + + + + + Einladungen verwalten · Vicky & Daniel + + + + + +
+
+
+

Vicky & Daniel

+

Gästeliste

+
+ +
+ + + + +
+ + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..d479bff --- /dev/null +++ b/public/admin.js @@ -0,0 +1,194 @@ +const loginPanel = document.querySelector("#login-panel"); +const loginForm = document.querySelector("#login-form"); +const keyInput = document.querySelector("#admin-key"); +const loginError = document.querySelector("#login-error"); +const dashboard = document.querySelector("#dashboard"); +const headerActions = document.querySelector("#header-actions"); +const createForm = document.querySelector("#create-form"); +const namesInput = document.querySelector("#guest-names"); +const createError = document.querySelector("#create-error"); +const createButton = document.querySelector("#create-button"); +const createdEmpty = document.querySelector("#created-empty"); +const createdLinks = document.querySelector("#created-links"); +const createdList = document.querySelector("#created-list"); +const responseBody = document.querySelector("#response-body"); +const tableEmpty = document.querySelector("#table-empty"); + +let adminKey = sessionStorage.getItem("inviteAdminKey") || ""; +let lastCreated = []; + +const answerLabels = { + not_coming: "Kommt nicht", + alone: "Kommt alleine", + with_someone: "Mit Begleitung" +}; + +function authHeaders(extra = {}) { + return { ...extra, Authorization: `Bearer ${adminKey}` }; +} + +function formatDate(value) { + if (!value) return "—"; + return new Intl.DateTimeFormat("de-AT", { + dateStyle: "short", + timeStyle: "short", + timeZone: "Europe/Vienna" + }).format(new Date(value)); +} + +async function copyText(text, button) { + await navigator.clipboard.writeText(text); + const old = button.textContent; + button.textContent = "Kopiert!"; + setTimeout(() => { button.textContent = old; }, 1200); +} + +function cell(row, text) { + const td = document.createElement("td"); + td.textContent = text; + row.append(td); + return td; +} + +function renderTable(invites) { + responseBody.replaceChildren(); + tableEmpty.hidden = invites.length > 0; + for (const invite of [...invites].reverse()) { + const row = document.createElement("tr"); + cell(row, invite.guestName); + const statusCell = document.createElement("td"); + const status = document.createElement("span"); + status.className = `status ${invite.answer || ""}`; + status.textContent = answerLabels[invite.answer] || (invite.firstOpenedAt ? "Geöffnet" : "Noch offen"); + statusCell.append(status); + row.append(statusCell); + cell(row, invite.companionName || "—"); + cell(row, formatDate(invite.firstOpenedAt)); + cell(row, invite.lastOpenedAt ? `${formatDate(invite.lastOpenedAt)} (${invite.openCount}×)` : "—"); + cell(row, formatDate(invite.lastSavedAt)); + const linkCell = document.createElement("td"); + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.className = "link-button"; + copyButton.textContent = "Link kopieren"; + copyButton.addEventListener("click", () => copyText(invite.link, copyButton)); + linkCell.append(copyButton); + row.append(linkCell); + responseBody.append(row); + } +} + +function renderCreated(invites) { + lastCreated = invites; + createdList.replaceChildren(); + createdEmpty.hidden = true; + createdLinks.hidden = false; + for (const invite of invites) { + const item = document.createElement("li"); + const name = document.createElement("strong"); + name.textContent = invite.name; + const link = document.createElement("a"); + link.href = invite.link; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = invite.link; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Kopieren"; + button.addEventListener("click", () => copyText(invite.link, button)); + item.append(name, link, button); + createdList.append(item); + } +} + +async function loadDashboard() { + const response = await fetch("/api/admin/invites", { headers: authHeaders() }); + if (!response.ok) throw new Error("Der Admin-Schlüssel ist nicht gültig."); + const data = await response.json(); + document.querySelector("#stat-total").textContent = data.summary.total; + document.querySelector("#stat-opened").textContent = data.summary.opened; + document.querySelector("#stat-responded").textContent = data.summary.responded; + document.querySelector("#stat-attending").textContent = data.summary.attending; + renderTable(data.invites); + loginPanel.hidden = true; + dashboard.hidden = false; + headerActions.hidden = false; +} + +loginForm.addEventListener("submit", async event => { + event.preventDefault(); + loginError.hidden = true; + adminKey = keyInput.value; + try { + await loadDashboard(); + sessionStorage.setItem("inviteAdminKey", adminKey); + } catch (error) { + loginError.textContent = error.message; + loginError.hidden = false; + } +}); + +createForm.addEventListener("submit", async event => { + event.preventDefault(); + createError.hidden = true; + const names = namesInput.value.split(/\r?\n/).map(name => name.trim()).filter(Boolean); + createButton.disabled = true; + createButton.textContent = "Links werden erstellt …"; + try { + const response = await fetch("/api/admin/invites", { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ names }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Links konnten nicht erstellt werden."); + renderCreated(data.invites); + namesInput.value = ""; + await loadDashboard(); + } catch (error) { + createError.textContent = error.message; + createError.hidden = false; + } finally { + createButton.disabled = false; + createButton.textContent = "Persönliche Links erstellen"; + } +}); + +document.querySelector("#copy-all-button").addEventListener("click", event => { + const text = lastCreated.map(invite => `${invite.name}: ${invite.link}`).join("\n"); + copyText(text, event.currentTarget); +}); + +document.querySelector("#refresh-button").addEventListener("click", async event => { + event.currentTarget.disabled = true; + try { await loadDashboard(); } finally { event.currentTarget.disabled = false; } +}); + +document.querySelector("#logout-button").addEventListener("click", () => { + sessionStorage.removeItem("inviteAdminKey"); + window.location.reload(); +}); + +document.querySelector("#csv-button").addEventListener("click", async event => { + event.currentTarget.disabled = true; + try { + const response = await fetch("/api/admin/responses.csv", { headers: authHeaders() }); + if (!response.ok) throw new Error("CSV konnte nicht geladen werden."); + const blob = await response.blob(); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "zusagen.csv"; + link.click(); + URL.revokeObjectURL(link.href); + } finally { + event.currentTarget.disabled = false; + } +}); + +loadDashboard().catch(() => { + sessionStorage.removeItem("inviteAdminKey"); + adminKey = ""; + loginPanel.hidden = false; + dashboard.hidden = true; + headerActions.hidden = true; +}); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..778d4e4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,124 @@ +const loading = document.querySelector("#loading"); +const form = document.querySelector("#rsvp-form"); +const invalidState = document.querySelector("#invalid-state"); +const invalidMessage = document.querySelector("#invalid-message"); +const successState = document.querySelector("#success-state"); +const greeting = document.querySelector("#guest-greeting"); +const companionWrap = document.querySelector("#companion-wrap"); +const companionInput = document.querySelector("#companion-name"); +const errorBox = document.querySelector("#form-error"); +const submitButton = document.querySelector("#submit-button"); +const updateNote = document.querySelector("#update-note"); +const changeButton = document.querySelector("#change-button"); +const successCopy = document.querySelector("#success-copy"); + +const tokenMatch = window.location.pathname.match(/^\/i\/([A-Za-z0-9_-]+)\/?$/); +const token = tokenMatch?.[1]; + +function show(element) { element.hidden = false; } +function hide(element) { element.hidden = true; } + +function toggleCompanion() { + const selected = form.elements.answer.value; + companionWrap.hidden = selected !== "with_someone"; + companionInput.required = selected === "with_someone"; +} + +function populate(invite) { + greeting.textContent = invite.guestName ? `Hallo ${invite.guestName}!` : "Hallo!"; + if (invite.answer) { + const radio = form.querySelector(`[name="answer"][value="${invite.answer}"]`); + if (radio) radio.checked = true; + companionInput.value = invite.companionName || ""; + show(updateNote); + submitButton.textContent = "Antwort aktualisieren"; + } + toggleCompanion(); +} + +async function loadInvitation() { + if (!token) { + hide(loading); + show(invalidState); + invalidMessage.textContent = "Bitte öffne den persönlichen Link aus deiner Einladung."; + return; + } + + try { + const response = await fetch(`/api/invites/${encodeURIComponent(token)}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Einladung nicht gefunden."); + populate(data); + hide(loading); + show(form); + } catch (error) { + hide(loading); + show(invalidState); + invalidMessage.textContent = error.message; + } +} + +form.addEventListener("change", event => { + if (event.target.name === "answer") { + hide(errorBox); + toggleCompanion(); + if (!companionWrap.hidden) companionInput.focus(); + } +}); + +form.addEventListener("submit", async event => { + event.preventDefault(); + hide(errorBox); + const answer = form.elements.answer.value; + const companionName = companionInput.value.trim(); + + if (!answer) { + errorBox.textContent = "Bitte wähle eine Antwort aus."; + show(errorBox); + return; + } + if (answer === "with_someone" && !companionName) { + errorBox.textContent = "Bitte verrate uns den Namen deiner Begleitung."; + show(errorBox); + companionInput.focus(); + return; + } + + submitButton.disabled = true; + submitButton.textContent = "Wird gespeichert …"; + try { + const response = await fetch(`/api/invites/${encodeURIComponent(token)}/rsvp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answer, companionName }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Antwort konnte nicht gespeichert werden."); + + const messages = { + not_coming: "Schade, dass du diesmal nicht dabei sein kannst. Danke fürs Bescheidgeben!", + alone: "Wunderbar – wir freuen uns darauf, mit dir zu feiern!", + with_someone: `Wunderbar – wir freuen uns auf euch${data.companionName ? ` und besonders auch auf ${data.companionName}` : ""}!` + }; + successCopy.textContent = messages[data.answer]; + hide(form); + show(successState); + successState.focus(); + } catch (error) { + errorBox.textContent = error.message; + show(errorBox); + } finally { + submitButton.disabled = false; + submitButton.textContent = "Antwort aktualisieren"; + } +}); + +changeButton.addEventListener("click", () => { + hide(successState); + show(form); + show(updateNote); + submitButton.textContent = "Antwort aktualisieren"; + form.querySelector("input:checked")?.focus(); +}); + +loadInvitation(); diff --git a/public/einladung.png b/public/einladung.png new file mode 100644 index 0000000..2ca222a Binary files /dev/null and b/public/einladung.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..45c934c --- /dev/null +++ b/public/index.html @@ -0,0 +1,96 @@ + + + + + + + + Vicky & Daniel werden 30 + + + + + +
+
+

Einladung zum 30. Geburtstag von Vicky und Daniel

+ + Einladung: Vicky und Daniel feiern am 14. November 2026 um 18 Uhr in der Bar 28, Gartengasse 28, 8010 Graz. + + + + Einladung als PDF + +
+ +
+
14 · 11 · 2026
+
+ + Einladung wird geladen +
+ + + + + + + + +
+
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..37d881f --- /dev/null +++ b/public/styles.css @@ -0,0 +1,273 @@ +:root { + --paper: #f8f4ef; + --white: #fffdf9; + --ink: #006880; + --ink-dark: #07556b; + --turquoise: #18b9bb; + --mint: #53c85a; + --orange: #f08b45; + --line: #d8e9e6; + --shadow: 0 24px 65px rgba(15, 78, 88, .14); +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink-dark); + background-color: var(--paper); + background-image: + radial-gradient(circle at 4% 10%, rgba(27, 185, 187, .18) 0 4px, transparent 5px), + radial-gradient(circle at 93% 21%, rgba(82, 200, 90, .20) 0 5px, transparent 6px), + radial-gradient(circle at 13% 82%, rgba(240, 139, 69, .14) 0 3px, transparent 4px), + radial-gradient(circle at 85% 89%, rgba(0, 151, 178, .15) 0 3px, transparent 4px); + font-family: Georgia, "Times New Roman", serif; + line-height: 1.5; +} + +button, input { font: inherit; } + +.skip-link { + position: fixed; + z-index: 20; + top: .75rem; + left: .75rem; + padding: .65rem 1rem; + color: white; + background: var(--ink); + border-radius: 999px; + transform: translateY(-200%); +} + +.skip-link:focus { transform: translateY(0); } + +.page-shell { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; + min-height: calc(100vh - 60px); + padding: 64px 0 36px; + display: grid; + grid-template-columns: minmax(300px, 500px) minmax(340px, 470px); + gap: clamp(40px, 7vw, 100px); + align-items: center; + justify-content: center; +} + +.invite-column { min-width: 0; text-align: center; } + +.invite-card { + display: block; + overflow: hidden; + border-radius: 5px; + box-shadow: var(--shadow), 0 0 0 1px rgba(0, 93, 111, .06); + transition: transform .25s ease, box-shadow .25s ease; +} + +.invite-card:hover { + transform: translateY(-4px) rotate(-.15deg); + box-shadow: 0 30px 78px rgba(15, 78, 88, .19); +} + +.invite-card img { display: block; width: 100%; height: auto; } + +.pdf-link { + display: inline-flex; + align-items: center; + gap: .5rem; + margin-top: 1.2rem; + color: var(--ink); + font: 700 .75rem/1.2 system-ui, sans-serif; + letter-spacing: .09em; + text-decoration: none; + text-transform: uppercase; +} + +.pdf-link:hover { text-decoration: underline; } +.pdf-link svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; } + +.rsvp-card { + position: relative; + overflow: hidden; + min-height: 540px; + padding: clamp(30px, 5vw, 54px); + background: rgba(255, 253, 249, .92); + border: 1px solid rgba(0, 104, 128, .1); + border-radius: 28px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.eyebrow { + margin-bottom: 1.15rem; + color: var(--turquoise); + font: 750 .74rem/1 system-ui, sans-serif; + letter-spacing: .26em; +} + +.hello { + margin: 0 0 .15rem; + color: var(--mint); + font-family: "Segoe Script", "Bradley Hand", cursive; + font-size: clamp(1.5rem, 4vw, 2rem); + font-weight: 700; +} + +h2 { + margin: 0; + color: var(--ink); + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 400; + line-height: 1.05; +} + +.intro { margin: 1rem 0 1.6rem; font-size: 1rem; } +fieldset { display: grid; gap: .7rem; margin: 0; padding: 0; border: 0; } + +.choice { + position: relative; + display: flex; + align-items: center; + gap: .9rem; + padding: .9rem 1rem; + cursor: pointer; + background: var(--white); + border: 1.5px solid var(--line); + border-radius: 15px; + transition: border-color .16s, background .16s, transform .16s; +} + +.choice:hover { border-color: #8ad4ce; transform: translateX(2px); } +.choice:has(input:checked) { background: #effbf8; border-color: var(--turquoise); } +.choice:has(input:focus-visible) { outline: 3px solid rgba(24, 185, 187, .25); outline-offset: 2px; } +.choice input { position: absolute; width: 1px; height: 1px; opacity: 0; } + +.choice-mark { + display: grid; + flex: 0 0 42px; + width: 42px; + height: 42px; + place-items: center; + color: var(--ink); + background: #edf8f6; + border-radius: 50%; +} + +.choice:has(input:checked) .choice-mark { color: white; background: var(--turquoise); } +.choice-mark svg { width: 23px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; } +.choice strong, .choice small { display: block; } +.choice strong { color: var(--ink-dark); font-size: .98rem; } +.choice small { margin-top: .1rem; color: #618087; font-family: system-ui, sans-serif; font-size: .74rem; } + +.companion { margin-top: 1.15rem; } +.companion label { display: block; margin-bottom: .4rem; font-weight: 700; } +.companion input { + width: 100%; + padding: .85rem 1rem; + color: var(--ink-dark); + background: white; + border: 1.5px solid var(--line); + border-radius: 12px; + outline: none; +} + +.companion input:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24, 185, 187, .15); } + +button { + width: 100%; + margin-top: 1.35rem; + padding: .95rem 1.2rem; + cursor: pointer; + color: white; + background: var(--ink); + border: 0; + border-radius: 999px; + font-family: system-ui, sans-serif; + font-size: .94rem; + font-weight: 750; + letter-spacing: .02em; + transition: background .18s, transform .18s; +} + +button:hover { background: var(--turquoise); transform: translateY(-1px); } +button:disabled { cursor: wait; opacity: .65; transform: none; } +button.secondary { color: var(--ink); background: transparent; border: 1.5px solid var(--ink); } +button.secondary:hover { color: white; background: var(--ink); } + +.form-error { margin: .8rem 0 -.5rem; color: #a43f28; font: 650 .84rem/1.4 system-ui, sans-serif; } +.update-note { margin: .8rem 0 0; color: #6a8488; font: .76rem/1.4 system-ui, sans-serif; text-align: center; } + +.state-message { padding-top: 4rem; text-align: center; } +.state-message p:not(.hello) { margin: 1.1rem auto; max-width: 29rem; } +.state-icon { + display: grid; + width: 64px; + height: 64px; + margin: 0 auto 1.6rem; + place-items: center; + color: white; + background: var(--orange); + border-radius: 50%; + font: 700 1.8rem/1 system-ui, sans-serif; +} + +.success .state-icon { background: var(--mint); } + +.loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--white); +} + +.loading span:not(.visually-hidden) { + width: 10px; + height: 10px; + background: var(--turquoise); + border-radius: 50%; + animation: bounce .8s infinite alternate; +} + +.loading span:nth-child(2) { animation-delay: .15s; background: var(--mint); } +.loading span:nth-child(3) { animation-delay: .3s; background: var(--orange); } + +.mini-confetti i { position: absolute; z-index: -1; width: 8px; height: 8px; border-radius: 50%; opacity: .55; } +.mini-confetti i:nth-child(1) { top: 5%; right: 9%; background: var(--mint); } +.mini-confetti i:nth-child(2) { top: 14%; right: 5%; width: 5px; height: 5px; background: var(--turquoise); } +.mini-confetti i:nth-child(3) { bottom: 10%; left: 6%; background: var(--orange); } +.mini-confetti i:nth-child(4) { bottom: 5%; right: 13%; width: 12px; height: 12px; background: var(--turquoise); } +.mini-confetti i:nth-child(5) { top: 3%; left: 41%; width: 5px; height: 5px; background: var(--orange); } +.mini-confetti i:nth-child(6) { bottom: 25%; right: 3%; width: 5px; height: 5px; background: var(--mint); } +.mini-confetti i:nth-child(7) { top: 31%; left: 2%; width: 4px; height: 4px; background: var(--turquoise); } +.mini-confetti i:nth-child(8) { bottom: 3%; left: 36%; width: 6px; height: 6px; background: var(--mint); } + +footer { padding: 0 1rem 24px; color: #668286; font: .72rem/1.4 system-ui, sans-serif; letter-spacing: .12em; text-align: center; text-transform: uppercase; } +.visually-hidden { position: absolute !important; overflow: hidden; width: 1px; height: 1px; padding: 0; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@keyframes bounce { to { transform: translateY(-10px); } } + +@media (max-width: 820px) { + .page-shell { width: min(590px, calc(100% - 28px)); padding-top: 28px; grid-template-columns: 1fr; gap: 34px; } + .invite-card { max-width: 480px; margin-inline: auto; } + .rsvp-card { min-height: 520px; } +} + +@media (max-width: 480px) { + .page-shell { width: 100%; padding: 0 0 24px; gap: 0; } + .invite-card { border-radius: 0; box-shadow: none; } + .pdf-link { margin: 1rem 0 1.4rem; } + .rsvp-card { min-height: 0; padding: 36px 20px 40px; border-inline: 0; border-radius: 28px 28px 0 0; } + .choice { padding: .82rem; } + .state-message { padding-block: 3rem; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } +} diff --git a/scripts/create-invite.js b/scripts/create-invite.js new file mode 100644 index 0000000..4bac7be --- /dev/null +++ b/scripts/create-invite.js @@ -0,0 +1,13 @@ +const crypto = require("node:crypto"); +const { createInvites } = require("../database"); + +const guestName = process.argv.slice(2).join(" ").trim(); +if (!guestName) { + console.error('Verwendung: npm run invite -- "Vorname Nachname"'); + process.exit(1); +} + +const token = crypto.randomBytes(18).toString("base64url"); +createInvites([{ token, guestName: guestName.slice(0, 100) }]); +const origin = (process.env.ORIGIN || "http://localhost:3000").replace(/\/$/, ""); +console.log(`${guestName}: ${origin}/i/${token}`); diff --git a/server.js b/server.js new file mode 100644 index 0000000..e214588 --- /dev/null +++ b/server.js @@ -0,0 +1,240 @@ +const http = require("node:http"); +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { createInvites, listInvites, recordOpen, saveRsvp } = require("./database"); + +const PORT = Number(process.env.PORT || 3000); +const HOST = process.env.HOST || "0.0.0.0"; +const ADMIN_KEY = process.env.ADMIN_KEY || ""; +const TRUST_AUTHENTIK = process.env.TRUST_AUTHENTIK === "true"; +const ORIGIN = (process.env.ORIGIN || `http://localhost:${PORT}`).replace(/\/$/, ""); +const PUBLIC_DIR = path.join(__dirname, "public"); + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".pdf": "application/pdf", + ".ico": "image/x-icon", + ".svg": "image/svg+xml" +}; + +function sendJson(response, status, payload) { + const body = JSON.stringify(payload); + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff" + }); + response.end(body); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let body = ""; + request.on("data", chunk => { + body += chunk; + if (body.length > 16_384) request.destroy(); + }); + request.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error("Ungültige Anfrage.")); + } + }); + request.on("error", reject); + }); +} + +function isAdmin(request, url) { + if (TRUST_AUTHENTIK && request.headers["x-authentik-username"]) return true; + if (!ADMIN_KEY) return false; + const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + const supplied = bearer || request.headers["x-admin-key"] || url.searchParams.get("key"); + if (!supplied) return false; + const expectedBuffer = Buffer.from(ADMIN_KEY); + const suppliedBuffer = Buffer.from(String(supplied)); + return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer); +} + +function createToken() { + return crypto.randomBytes(18).toString("base64url"); +} + +function safeInvite(invite) { + return { + guestName: invite.guestName || "", + answer: invite.answer || null, + companionName: invite.companionName || "", + respondedAt: invite.respondedAt || null + }; +} + +function adminInvite(invite) { + return { + guestName: invite.guestName || "", + token: invite.token, + link: `${ORIGIN}/i/${invite.token}`, + answer: invite.answer || null, + companionName: invite.companionName || "", + createdAt: invite.createdAt || null, + firstOpenedAt: invite.firstOpenedAt || null, + lastOpenedAt: invite.lastOpenedAt || null, + openCount: Number(invite.openCount || 0), + lastSavedAt: invite.respondedAt || null + }; +} + +function csvCell(value) { + const text = String(value ?? ""); + return `"${text.replaceAll('"', '""')}"`; +} + +function serveFile(response, requestedPath) { + const relative = requestedPath === "/" ? "index.html" : requestedPath.replace(/^\/+/, ""); + const filePath = path.resolve(PUBLIC_DIR, relative); + if (!filePath.startsWith(`${PUBLIC_DIR}${path.sep}`)) { + sendJson(response, 403, { error: "Nicht erlaubt." }); + return; + } + + fs.stat(filePath, (error, stats) => { + if (error || !stats.isFile()) { + sendJson(response, 404, { error: "Nicht gefunden." }); + return; + } + const extension = path.extname(filePath).toLowerCase(); + const headers = { + "Content-Type": MIME_TYPES[extension] || "application/octet-stream", + "X-Content-Type-Options": "nosniff", + "Cache-Control": extension === ".html" ? "no-cache" : "public, max-age=86400" + }; + if (extension === ".pdf") { + headers["Content-Disposition"] = 'inline; filename="Einladung-Vicky-und-Daniel.pdf"'; + } + response.writeHead(200, headers); + fs.createReadStream(filePath).pipe(response); + }); +} + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url, `http://${request.headers.host || "localhost"}`); + const route = decodeURIComponent(url.pathname); + + try { + if (request.method === "GET" && route === "/health") { + return sendJson(response, 200, { ok: true }); + } + + const inviteMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)$/); + if (request.method === "GET" && inviteMatch) { + const invite = recordOpen(inviteMatch[1], new Date().toISOString()); + if (!invite) return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." }); + return sendJson(response, 200, safeInvite(invite)); + } + + const rsvpMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/rsvp$/); + if (request.method === "POST" && rsvpMatch) { + const payload = await readJson(request); + const validAnswers = new Set(["not_coming", "alone", "with_someone"]); + if (!validAnswers.has(payload.answer)) { + return sendJson(response, 400, { error: "Bitte wähle eine Antwort aus." }); + } + + const companionName = String(payload.companionName || "").trim().slice(0, 100); + if (payload.answer === "with_someone" && !companionName) { + return sendJson(response, 400, { error: "Bitte verrate uns den Namen deiner Begleitung." }); + } + + const invite = saveRsvp( + rsvpMatch[1], + payload.answer, + payload.answer === "with_someone" ? companionName : "", + new Date().toISOString() + ); + if (!invite) { + return sendJson(response, 404, { error: "Dieser Einladungslink ist leider nicht gültig." }); + } + return sendJson(response, 200, { ok: true, ...safeInvite(invite) }); + } + + if (request.method === "POST" && route === "/api/admin/invites") { + if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); + const payload = await readJson(request); + const names = (Array.isArray(payload.names) ? payload.names : [payload.name || ""]) + .map(name => String(name || "").trim()) + .filter(Boolean); + if (!names.length) return sendJson(response, 400, { error: "Bitte gib mindestens einen Namen an." }); + if (names.length > 200) return sendJson(response, 400, { error: "Maximal 200 Einladungen auf einmal." }); + const created = createInvites(names.map(name => ({ token: createToken(), guestName: name.slice(0, 100) }))) + .map(invite => ({ name: invite.guestName, token: invite.token, link: `${ORIGIN}/i/${invite.token}` })); + return sendJson(response, 201, { invites: created }); + } + + if (request.method === "GET" && route === "/api/admin/invites") { + if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); + const invites = listInvites().map(adminInvite); + const summary = invites.reduce((totals, invite) => { + totals.total += 1; + if (invite.firstOpenedAt) totals.opened += 1; + if (invite.answer) totals.responded += 1; + if (invite.answer === "not_coming") totals.notComing += 1; + if (invite.answer === "alone") totals.attending += 1; + if (invite.answer === "with_someone") totals.attending += 2; + return totals; + }, { total: 0, opened: 0, responded: 0, attending: 0, notComing: 0 }); + return sendJson(response, 200, { summary, invites }); + } + + if (request.method === "GET" && route === "/api/admin/responses.csv") { + if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); + const labels = { not_coming: "Kommt nicht", alone: "Kommt alleine", with_someone: "Kommt mit Begleitung" }; + const rows = [["Gast", "Antwort", "Begleitung", "Erstmals geöffnet", "Zuletzt geöffnet", "Öffnungen", "Zuletzt gespeichert", "Erstellt", "Link"]]; + for (const invite of listInvites()) { + rows.push([ + invite.guestName, + labels[invite.answer] || "Noch offen", + invite.companionName, + invite.firstOpenedAt, + invite.lastOpenedAt, + invite.openCount || 0, + invite.respondedAt, + invite.createdAt, + `${ORIGIN}/i/${invite.token}` + ]); + } + const csv = `\uFEFF${rows.map(row => row.map(csvCell).join(";")).join("\n")}`; + response.writeHead(200, { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="zusagen.csv"', + "Content-Length": Buffer.byteLength(csv), + "Cache-Control": "no-store" + }); + return response.end(csv); + } + + if (request.method === "GET" && /^\/i\/[A-Za-z0-9_-]+\/?$/.test(route)) { + return serveFile(response, "/index.html"); + } + + if (request.method === "GET" && /^\/admin\/?$/.test(route)) { + return serveFile(response, "/admin.html"); + } + + if (request.method === "GET") return serveFile(response, route); + return sendJson(response, 405, { error: "Methode nicht erlaubt." }); + } catch (error) { + console.error(error); + return sendJson(response, 500, { error: "Da ist etwas schiefgegangen. Bitte versuche es noch einmal." }); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`Invitation server listening on ${ORIGIN}`); + if (!ADMIN_KEY && !TRUST_AUTHENTIK) console.warn("No admin authentication is configured; admin endpoints are disabled."); +});