Compare commits

..

43 Commits

Author SHA1 Message Date
admin 9363e17de3 Statistik: dritte Kachel 'Führungen' und Umbenennung 'Alle Events'
Mittlere Kachel von 'Führungen' in 'Alle Events' umbenannt (zeigt
Gesamtzahl aller Events) und neue Kachel 'Führungen' mit der Summe
aller Führungen ergänzt. Bump auf 1.10.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:22:33 +02:00
admin 978ed4e1da chore: feat/objekte-kategorie → main (Version 1.10.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:14:55 +02:00
admin 7475d4fd37 feat: Objekte-Kategorien (stern/sonne/beide) — Version 1.10.1
- Neue Spalte Kategorie (SET stern/sonne) in objekte-Tabelle
- ObjektSelector zeigt je nach ArtFuehrung nur passende Objekte
- SonnenFührung: Sonne fest vorausgewählt, zusätzliche Sonne-Objekte wählbar
- Bestehende Objekte erhalten Kategorie automatisch beim Speichern
- Admin: Kategorie editierbar (stern / sonne / stern,sonne)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:14:52 +02:00
admin 96ba03b909 fix: grafikProxy gibt 404 statt HTML zurück für JS/CSS-Assets bei Upstream-Fehler
Verhindert MIME-Type-Fehler im Browser wenn upstream-Server für
JS/CSS-Dateien eine HTML-Fehlerseite zurückgibt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 18:22:53 +02:00
admin 421b589169 fix: host.docker.internal für PHP-Bridge im Prod-Container
extra_hosts in docker-compose.prod.yml damit der Container
host.docker.internal:8080 (Sternwarte) erreichen kann.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:55:45 +02:00
admin d13e3b0ba9 chore: migrateDatabase → main (Version 1.10.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:48:18 +02:00
admin a75303f857 feat: Version 1.10.0 — DB-Zugriff auf PHP-Bridge (DB4js_all.php) umgestellt
- lib/db.ts entfernt, mysql2-Abhängigkeit gestrichen
- lib/phpdb.ts: HTTP-Client für alle DB-Operationen via DB4js_all.php
- Alle API-Routen und Server Actions auf phpdb.ts umgestellt
- compose.yml / docker-compose.prod.yml: MySQL/phpMyAdmin-Container entfernt
- app/api/DB4js_all.php/route.ts: Proxy für Statistik-AJAX-Calls
- Statistik-Grafik liest ab 2026 live aus logbuch statt StatistikJahre
- PHP 7.3-Kompatibilität: str_contains → strpos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:48:15 +02:00
admin c3f0b8f1e0 chore: Version 1.9.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:06:34 +02:00
admin 67dc253cd9 docs: anleitung.html aus ANLEITUNG.md aktualisiert
Führungsbuch statt Logbuch, Statistik-Sektion mit 4 Kacheln und
Grafik-Button, Suchfeld-Beschreibung aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:03:51 +02:00
admin 39bb94ebb7 fix: public-Ordner in Docker-Runner-Stage kopieren
Next.js standalone serviert /public nicht automatisch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:50:33 +02:00
admin 7571b14422 chore: Version 1.9.0 — SSH-Backup nach Logbuch-Eintrag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:57:37 +02:00
admin d88005d9fe feat: manueller Backup-Button für Admin
POST /api/backup (nur Admin) löst triggerBackup() aus.
Button im Header zeigt /✓/✗ Feedback, verschwindet nach 4s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:52:26 +02:00
admin 00a3f02d80 fix: lokale Dump-Datei immer löschen (try/finally)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:23:05 +02:00
admin 43ddbbcf72 feat: lokalen Dump nach erfolgreichem Upload löschen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:16:58 +02:00
admin 49563e6bd0 fix: backup — Remote-Verzeichnis per mkdir -p vor scp anlegen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:05:38 +02:00
admin 4316670ce4 fix: env_file statt Variablen-Substitution in docker-compose
env_file: .env lädt alle Variablen direkt in den Container,
unabhängig vom CWD beim docker-compose-Aufruf. environment:
überschreibt nur noch die drei Werte die vom .env abweichen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:28:54 +02:00
admin a12c62bbdc fix: AUTH_SECRET-Check lazy — wirft erst zur Laufzeit, nicht beim Build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:19:25 +02:00
admin 072ca040bb fix: .env nicht ins Docker-Image — Variablen kommen aus Compose/ENV
.dockerignore schließt .env aus dem Build-Kontext aus. Next.js
standalone hat dadurch keine eingebettete .env mehr und liest
Variablen sauber aus process.env (gesetzt via docker-compose
environment:). NEXT_PUBLIC_FAHRKOSTEN_SATZ bleibt als Build-ARG
verfügbar (Default 15). BACKUP_SSH_KEY_FILE default /dev/null
damit Compose auch ohne Backup-Konfiguration startet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:18:15 +02:00
admin 52234132ca fix: Dump via mysql2 statt mariadb-dump
MariaDB-Client kennt caching_sha2_password (MySQL-8-Default) nicht.
Der mysql2-Node.js-Treiber implementiert das Plugin nativ und
verbindet sich problemlos. Der Dump schreibt CREATE TABLE +
INSERT-Batches (200 Zeilen) direkt via gzip in die lokale Datei.
Keine externen Binaries mehr für den Dump-Schritt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:54:41 +02:00
admin 93b449412f fix: backup — mysql_native_password für MariaDB-Client gegen MySQL 8
mariadb-dump kennt caching_sha2_password nicht (MySQL-8-Default).
--default-auth=mysql_native_password umgeht das. Außerdem wird
der Exit-Code des Dump-Prozesses jetzt ausgewertet — fehlerhafte
Dumps werden erkannt statt als leere Datei abgelegt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:44:22 +02:00
admin c3bac456e7 refactor: backup schreibt Dump zuerst lokal, dann scp
Statt direkter pipe dump→gzip→ssh wird der Dump jetzt in
BACKUP_LOCAL_DIR (default /tmp/logbuch-backup) geschrieben
und danach per scp übertragen. So ist der Dump jederzeit
im Container einsehbar; SSH bleibt optional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:23:59 +02:00
admin fb0b64c36c fix: backup blockiert HTTP-Response nicht mehr
setImmediate() startet Backup außerhalb des Next.js Request-Kontexts,
ConnectTimeout=15 verhindert hängende SSH-Verbindungen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:06:17 +02:00
admin 2e875ed1ad fix: backup — mariadb-dump, --skip-ssl, SSH-Key per Volume in Compose
- mariadb-dump statt mysqldump (kein Deprecation-Warning in Alpine)
  via BACKUP_DUMP_CMD konfigurierbar (Fallback für lokale MySQL-Umgebung)
- --skip-ssl unterdrückt MariaDB-SSL-Warnung bei MYSQL_PWD-Nutzung
- docker-compose.prod.yml: BACKUP_SSH_URL + Key-Volume (BACKUP_SSH_KEY_FILE)
  damit SSH-Alias-Auflösung und Key-Zugriff im Container funktionieren

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:54:43 +02:00
admin d99a696ef0 fix: backup — MYSQL_PWD statt -p Flag, SSH-Key optional
Passwort via MYSQL_PWD-Env statt -p vermeidet die mysqldump-Warnung
und ist sicherer. BACKUP_SSH_KEY_PATH ist jetzt optional: wenn leer,
wird kein -i übergeben und SSH nutzt seine eigene Konfiguration
(~/.ssh/config, ssh-agent). So funktionieren SSH-Config-Aliases
(z.B. 'strato_1') ohne Key-Override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:46:54 +02:00
admin 10b52d268e fix: backup — error-Events auf spawn abfangen, Tilde in Key-Pfad expandieren
Ohne 'error'-Handler auf den Child-Prozessen führt spawn ENOENT zu
uncaughtException statt zu einem gefangenen Promise-Reject. Außerdem
wird '~' im BACKUP_SSH_KEY_PATH jetzt manuell zu $HOME expandiert,
da spawn keine Shell-Expansion macht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:29:38 +02:00
admin cf95f3027f feat: automatisches SSH-Backup nach jedem Logbuch-Eintrag
Nach jedem POST und PUT im Logbuch wird mysqldump (ohne beos-Tabelle)
via gzip | ssh auf einen externen Server übertragen. Backups älter als
30 Tage werden automatisch gelöscht. BACKUP_SSH_URL und
BACKUP_SSH_KEY_PATH in .env konfigurieren; SSH-Key als Volume mounten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:21:01 +02:00
admin 8c60089325 fix: iOS/iPad text color — text-gray-900 on all inputs, tables, headings
Alle Eingabefelder, Tabellenzellen und Überschriften ohne explizite
Textfarbe wurden mit text-gray-900 versehen. iOS rendert sonst
system-default-Farben, die auf weißem Hintergrund kaum lesbar sind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:07:18 +02:00
admin 3ab4779ee5 chore: Version 1.8.0 — Führungsbuch Umbenennung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:41:57 +02:00
admin 9cb22b1a53 Merge branch 'umbenannt': Logbuch → Führungsbuch (UI-Strings) 2026-06-02 22:41:47 +02:00
admin 50b74c4e92 Umbenennung. Logbuch -> Führungsbuch 2026-06-02 22:40:54 +02:00
admin e73680a70d fix: middleware.ts → proxy.ts (Next.js 16 Konvention)
Next.js 16 erwartet proxy.ts mit export function proxy(),
nicht middleware.ts. Deprecation-Warnung damit beseitigt.
CLAUDE.md mit korrekter Konvention aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:12:16 +02:00
admin 1f70940dce fix: Middleware aktiviert — proxy.ts → middleware.ts
proxy.ts wurde nie als Next.js-Middleware erkannt (falscher Dateiname,
falscher Exportname). Die mustChangePassword-Weiterleitung und der
Login-Guard liefen daher nie. Zusätzlich Fallback-Secret entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:07:21 +02:00
admin a59afefb07 Merge branch 'backup': backup_db.sh dazu gebaut 2026-05-31 17:44:39 +00:00
admin ce365e3e79 V 1.7.9: backup_db.sh dazu gebaut 2026-05-31 17:42:07 +00:00
admin 69c31637bb Remove CI/CD workflows and docs 2026-05-31 15:57:17 +00:00
admin 1ef4f0d9da CI: add Drone pipeline and clarify Gitea auto-deploy support
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:55:05 +00:00
admin b4f57be2e7 Test commit
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:47:58 +00:00
admin 03c65c78ed Docs: add Gitea instructions for CI secrets
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:41:48 +00:00
admin ac77606475 Docs: add CI/CD secrets instructions to ANLEITUNG.md
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:38:27 +00:00
admin d718d78210 CI: add GitHub Actions workflow to build and push Docker image
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:37:17 +00:00
admin 27f2d438e2 Bump version to 1.7.8 2026-05-31 15:34:34 +00:00
admin d94de334d7 V1.7.7 Version updatet 2026-05-31 13:32:54 +00:00
admin d5bd359802 feat: Statistik komplett neu gestaltet — kuppelunabhängig, feste Spalten
- Statistik gilt jetzt für alle Kuppeln gemeinsam (kein Kuppel-Filter mehr)
- Neue Tabellenstruktur: Besucher (RF/SF/SonF/PrF/ToT/Gesamt) | Anzahl (Führ./Beob./TD/Sonst./BEOS/ToT/Gesamt)
- Anzahl zählt Einträge (COUNT(*)) statt distinkte Tage
- Zusammenfassungskacheln auf 2 reduziert (Besucher + Führungen gesamt)
- Fix: Besucher-State wird zurückgesetzt wenn ArtFuehrung auf TD/BEOS wechselt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:29:08 +02:00
46 changed files with 1397 additions and 804 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Logbuch Dev",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 3306],
"portsAttributes": {
"3000": {
"label": "Next.js Dev Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "silent"
}
},
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next"
]
}
}
}
+17
View File
@@ -0,0 +1,17 @@
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:20
volumes:
- ..:/workspace:cached
environment:
DB_HOST: host.docker.internal
DB_USER: logbuch
DB_PASS: Ds!?f?f6X5B2
DB_NAME: sternwarte
DB_PORT: 3336
AUTH_SECRET: 75c3075e200d50f2273c60edcea5aca58796831e2c99ce2a69fca0005d5920cd
DEFAULT_PASSWORD: welzheim
NODE_ENV: development
extra_hosts:
- "host.docker.internal:host-gateway"
command: sleep infinity
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
.git
.gitignore
node_modules
.next
*.md
+7 -4
View File
@@ -1,4 +1,4 @@
# Bedienungsanleitung Logbuch Sternwarte Welzheim # Bedienungsanleitung Führungsbuch Sternwarte Welzheim
## Inhaltsverzeichnis ## Inhaltsverzeichnis
@@ -19,7 +19,7 @@ Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`) - **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
- **Passwort**: individuell gesetztes Passwort - **Passwort**: individuell gesetztes Passwort
Wurde das Passwort noch nicht geändert (Anzeige „Standard"), muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`. Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
--- ---
@@ -109,7 +109,7 @@ Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Ei
Die Werkzeugleiste oben in der Liste enthält in einer Zeile: Die Werkzeugleiste oben in der Liste enthält in einer Zeile:
- **Monatsnavigation** Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; **Aktueller Monat** springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt). - **Monatsnavigation** Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; **Aktueller Monat** springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).
- **Suchfeld** Freitextsuche über alle Einträge des Logbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück. - **Suchfeld** Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.
- **🖨 Drucken** siehe Abschnitt [Drucken](#6-drucken). - **🖨 Drucken** siehe Abschnitt [Drucken](#6-drucken).
### Tabelleninhalt ### Tabelleninhalt
@@ -141,6 +141,8 @@ Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsse
- Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln) - Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)
- Führungstage für die gesamte Sternwarte - Führungstage für die gesamte Sternwarte
- **Grafik** Über diesen Button kann die (bekannte) Statistik-Grafik aufgerufen werden. Sie wird in ein einem gesonderten Fenster angezeigt. Zurück zum Führungsbuch kommt man über den Tab-Wechsel im Browser.
--- ---
## 6. Drucken ## 6. Drucken
@@ -174,4 +176,5 @@ Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken. - **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken.
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen. - **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
- **Objekt löschen**: × in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Logbucheinträgen. - **Objekt löschen**: × in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.
+1 -1
View File
@@ -16,7 +16,7 @@ No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Do
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`. Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement. **Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. Middleware lives in `proxy.ts` (Next.js 16 convention) and exports `middleware` (not `proxy`).
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`. **Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`.
+5
View File
@@ -12,7 +12,9 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG BUILD_DATE ARG BUILD_DATE
ARG NEXT_PUBLIC_FAHRKOSTEN_SATZ=15
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_PUBLIC_FAHRKOSTEN_SATZ=${NEXT_PUBLIC_FAHRKOSTEN_SATZ}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
@@ -23,11 +25,14 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache mysql-client openssh-client
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs USER nextjs
+32
View File
@@ -34,3 +34,35 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Neue Features (Statistik Grafik Proxy)
- Proxy für das interne Statistik-Portal:
- Neuer Server-seitiger Proxy unter `/api/statistik/grafik` (und Catch-all `/api/statistik/grafik/*`).
- Holt die Statistik-Seite serverseitig mit Basic-Auth und gibt sie an den Browser weiter, damit Zugangsdaten nicht im Client landen.
- Leitet auch CSS/JS/Images und AJAX-POSTs durch den Proxy (weitergeleitete Methoden und Bodies werden erhalten).
- Die HTML-Antwort wird bereinigt und relative URLs so umgeschrieben, dass Assets über die Proxy-URL geladen werden (`<base href="/api/statistik/grafik/">`).
- Environment-Variablen (server-side only):
- `STATISTIK_GRAFIK_URL` — Basis-URL des internen Statistik-Portals (z. B. `https://sternwarte-welzheim.de/intern/statistik`).
- `STATISTIK_GRAFIK_USER` — Benutzername für Basic-Auth.
- `STATISTIK_GRAFIK_PASS` — Passwort für Basic-Auth.
- UI-Änderung:
- Der `Grafik`-Button in der Statistik-Ansicht öffnet jetzt die Statistik-Seite in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, da Browser (insb. Firefox/Safari) bei Einbettung Probleme mit X-Frame-Options/CSP gemacht haben.
- Middleware / Sicherheit:
- Die Proxy-Route wird in der App-Auth-Middleware erlaubt, so dass der Proxy die Statistik-Seite auch ohne Benutzer-Session laden kann (Zugangskontrolle erfolgt über die serverseitigen Basic-Auth-Variablen).
- Datenbank-Backup:
- Das neue Skript `backup_db.sh` erzeugt ein tägliches MySQL-Dump-Backup im Verzeichnis `DB_BACKUP/`.
- Standardmäßig werden Backups älter als 7 Tage automatisch gelöscht.
- Beispiel-Cron-Eintrag für täglich 02:00 Uhr:
```cron
0 2 * * * cd /pfad/zum/repo && DB_ROOT_PASS=deinrootpass DB_NAME=sternwarte ./backup_db.sh --days 7
```
Hinweis: Speichere sensible Zugangsdaten nicht in Repositories. Setze die drei `STATISTIK_...` Variablen in deiner Deployment-Umgebung (z. B. Docker secrets, CI/CD environment variables oder auf dem Server). Die Proxy-Implementierung entfernt framing-blockierende Header und schiebt relative Asset-Pfade durch den Proxy, um Kompatibilitätsprobleme mit Browsern zu vermeiden.
Test, ob das nun Deployed, wenn auf main gepushed wird.
+46 -8
View File
@@ -21,6 +21,9 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe'); const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null); const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
const grafikSrc = '/api/statistik/grafik';
const version = packageJson.version; const version = packageJson.version;
const buildDate = const buildDate =
@@ -45,24 +48,48 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
window.location.href = '/login'; window.location.href = '/login';
} }
async function handleBackup() {
setBackupState('running');
try {
const r = await fetch('/api/backup', { method: 'POST' });
setBackupState(r.ok ? 'ok' : 'error');
} catch {
setBackupState('error');
}
setTimeout(() => setBackupState('idle'), 4000);
}
return ( return (
<div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0"> <div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF4FF] print:max-w-none print:border-0 print:p-0 print:bg-white"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF4FF] print:max-w-none print:border-0 print:p-0 print:bg-white">
{/* Header */} {/* Header */}
<div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden"> <div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden">
<h1 className="text-xl sm:text-2xl font-bold leading-tight"> <h1 className="text-xl sm:text-2xl font-bold leading-tight text-gray-900">
<span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span> <span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Logbuch für {activeKuppel}-Kuppel Führungsbuch für {activeKuppel}-Kuppel
</h1> </h1>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{role?.includes('admin') && ( {role?.includes('admin') && (
<>
<button
onClick={handleBackup}
disabled={backupState === 'running'}
className={`text-xs sm:text-sm px-2 sm:px-3 py-1.5 rounded-lg text-gray-900 disabled:opacity-50 ${
backupState === 'ok' ? 'bg-green-200' :
backupState === 'error' ? 'bg-red-200' :
'bg-gray-200 hover:bg-gray-300'
}`}
>
{backupState === 'running' ? '⏳ Backup…' : backupState === 'ok' ? '✓ Backup OK' : backupState === 'error' ? '✗ Fehler' : '💾 Backup'}
</button>
<a <a
href="/admin" href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
> >
Admin Admin
</a> </a>
</>
)} )}
<button <button
onClick={handleLogout} onClick={handleLogout}
@@ -98,7 +125,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
.map((tab) => ( .map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }} onClick={() => {
setActiveTab(tab);
if (tab === 'eingabe') setEditEntry(null);
}}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab activeTab === tab
? 'border-[#85B7D7] text-gray-900' ? 'border-[#85B7D7] text-gray-900'
@@ -156,7 +186,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{activeTab === 'liste' && ( {activeTab === 'liste' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0"> <div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="hidden print:block mb-4"> <div className="hidden print:block mb-4">
<div className="text-lg font-bold">Sternwarte Welzheim Logbuch {activeKuppel}-Kuppel</div> <div className="text-lg font-bold">Sternwarte Welzheim Führungsbuch {activeKuppel}-Kuppel</div>
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div> <div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div> </div>
<LogbuchList <LogbuchList
@@ -173,16 +203,24 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{/* Statistik-Tab */} {/* Statistik-Tab */}
{activeTab === 'statistik' && ( {activeTab === 'statistik' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0"> <div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden"> <div className="flex justify-between items-center mb-2 print:hidden gap-2">
<span className="text-sm font-semibold text-gray-600">Statistik {activeKuppel}-Kuppel</span> <span className="text-sm font-semibold text-gray-900">Statistik (alle Kuppeln)</span>
<div className="flex items-center gap-2">
<button <button
onClick={() => window.print()} onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg" className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
> >
🖨 Drucken 🖨 Drucken
</button> </button>
<button
onClick={() => window.open(grafikSrc, '_blank')}
className="text-sm px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
📊 Grafik
</button>
</div> </div>
<Statistik kuppel={activeKuppel} /> </div>
<Statistik />
</div> </div>
)} )}
@@ -190,7 +228,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && ( {activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0"> <div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden"> <div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Fahrkostenabrechnung</span> <span className="text-sm font-semibold text-gray-900">Fahrkostenabrechnung</span>
<button <button
onClick={() => window.print()} onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg" className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
+37 -4
View File
@@ -12,7 +12,9 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const router = useRouter(); const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editKategorie, setEditKategorie] = useState<string>('stern');
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newKategorie, setNewKategorie] = useState<string>('stern');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -24,7 +26,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const res = await fetch('/api/objekte/' + id, { const res = await fetch('/api/objekte/' + id, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }), body: JSON.stringify({ name: trimmed, kategorie: editKategorie }),
}); });
setBusy(false); setBusy(false);
if (!res.ok) { if (!res.ok) {
@@ -59,7 +61,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const res = await fetch('/api/objekte', { const res = await fetch('/api/objekte', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }), body: JSON.stringify({ name: trimmed, kategorie: newKategorie }),
}); });
setBusy(false); setBusy(false);
if (!res.ok) { if (!res.ok) {
@@ -85,6 +87,15 @@ export default function ObjekteManager({ initialObjekte }: Props) {
placeholder="Neues Objekt…" placeholder="Neues Objekt…"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
/> />
<select
value={newKategorie}
onChange={(e) => setNewKategorie(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
<button <button
type="submit" type="submit"
disabled={busy} disabled={busy}
@@ -100,6 +111,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
<tr> <tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th> <th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">Name</th> <th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Kategorie</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th> <th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th>
<th className="px-4 py-3 w-36"></th> <th className="px-4 py-3 w-36"></th>
</tr> </tr>
@@ -125,6 +137,27 @@ export default function ObjekteManager({ initialObjekte }: Props) {
obj.Name obj.Name
)} )}
</td> </td>
<td className="px-4 py-2 hidden sm:table-cell">
{editingId === obj.ID ? (
<select
value={editKategorie}
onChange={(e) => setEditKategorie(e.target.value)}
className="px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
) : (
<span className="flex gap-1">
{obj.Kategorie.split(',').map((k) => (
<span key={k} className={`text-xs px-2 py-0.5 rounded-full font-medium ${k === 'sonne' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
{k}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-500 hidden sm:table-cell"> <td className="px-4 py-2 text-gray-500 hidden sm:table-cell">
{obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'} {obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'}
</td> </td>
@@ -151,7 +184,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
<span className="flex justify-end gap-2"> <span className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setError(''); }} onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setEditKategorie(obj.Kategorie); setError(''); }}
disabled={busy} disabled={busy}
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200 disabled:opacity-50" className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200 disabled:opacity-50"
> >
@@ -172,7 +205,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
))} ))}
{initialObjekte.length === 0 && ( {initialObjekte.length === 0 && (
<tr> <tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td> <td colSpan={5} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td>
</tr> </tr>
)} )}
</tbody> </tbody>
+9 -26
View File
@@ -2,39 +2,27 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import { query } from '@/lib/db'; import * as phpdb from '@/lib/phpdb';
export type { BeoUser } from '@/lib/phpdb';
export interface ObjektRow { export interface ObjektRow {
ID: number; ID: number;
Name: string; Name: string;
LastUsed: string | null; LastUsed: string | null;
Kategorie: string;
} }
export async function listObjekte(): Promise<ObjektRow[]> { export async function listObjekte(): Promise<ObjektRow[]> {
const session = await getSession(); const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/'); if (!session || !session.role?.includes('admin')) redirect('/');
const rows = await query('SELECT ID, Name, LastUsed FROM objekte ORDER BY Name ASC', []); return phpdb.listObjekteAdmin();
return rows as ObjektRow[];
} }
export interface BeoUser { export async function listUsers(): Promise<phpdb.BeoUser[]> {
id: number;
kürzel: string | null;
name: string;
vorname: string | null;
role: string | null;
hasPw: boolean;
}
export async function listUsers(): Promise<BeoUser[]> {
const session = await getSession(); const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/'); if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listUsers();
const rows = await query(
'SELECT id, `kürzel`, name, vorname, role, (pw IS NOT NULL) AS hasPw FROM beos ORDER BY name, vorname',
[]
) as (Omit<BeoUser, 'hasPw'> & { hasPw: number })[];
return rows.map(r => ({ ...r, hasPw: r.hasPw === 1 }));
} }
export async function resetPassword( export async function resetPassword(
@@ -46,16 +34,11 @@ export async function resetPassword(
return { error: 'Keine Berechtigung.' }; return { error: 'Keine Berechtigung.' };
} }
const idRaw = formData.get('id'); const id = Number(formData.get('id'));
const id = Number(idRaw);
if (!id || isNaN(id)) { if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' }; return { error: 'Ungültige Benutzer-ID.' };
} }
await query( await phpdb.resetBeoPassword(id);
'UPDATE beos SET pw = NULL, MustChangePassword = 1 WHERE id = ?',
[id]
);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' }; return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
} }
+1 -1
View File
@@ -33,7 +33,7 @@ export default async function AdminPage({
<div className="min-h-screen bg-white py-4 px-4"> <div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link> <Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
const DB_URL = (process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php')
.replace(/\?.*$/, '');
async function proxy(req: Request) {
const search = new URL(req.url).search;
const target = DB_URL + search;
const isReadOnly = req.method === 'GET' || req.method === 'HEAD';
const upstream = await fetch(target, {
method: req.method,
headers: { 'content-type': req.headers.get('content-type') ?? 'application/x-www-form-urlencoded' },
body: isReadOnly ? undefined : await req.arrayBuffer(),
cache: 'no-store',
});
const body = await upstream.arrayBuffer();
return new NextResponse(Buffer.from(body), {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
export const GET = proxy;
export const POST = proxy;
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
export async function POST() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
triggerBackup();
return NextResponse.json({ ok: true });
}
+2 -4
View File
@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET() { export async function GET() {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { try {
const rows = await query( const rows = await phpdb.getBeos();
'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL AND FIND_IN_SET(\'guide\', role) > 0 ORDER BY name ASC'
) as { ID: number; Kuerzel: string; Name: string }[];
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/beos:', error); console.error('GET /api/beos:', error);
+3 -21
View File
@@ -1,15 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export interface FahrkostenRow { export type { FahrkostenRow } from '@/lib/phpdb';
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
const EXCLUDED = "'PrF','Beob','BEOS','TD','ToT'";
export async function GET(req: Request) { export async function GET(req: Request) {
const session = await getSession(); const session = await getSession();
@@ -22,18 +15,7 @@ export async function GET(req: Request) {
} }
try { try {
const rows = await query( const rows = await phpdb.getFahrkosten(ab);
'SELECT b.id AS ID, b.`kürzel` AS Kuerzel,' +
' CONCAT(IFNULL(b.vorname, \'\'), IF(b.vorname IS NOT NULL, \' \', \'\'), b.name) AS Name,' +
' COUNT(DISTINCT l.ID) AS Anzahl' +
' FROM beos b' +
' JOIN logbuch_beos lb ON lb.BeoID = b.id' +
' JOIN logbuch l ON l.ID = lb.LogbuchID' +
' WHERE l.Beginn >= ? AND l.ArtFuehrung NOT IN (' + EXCLUDED + ')' +
' GROUP BY b.id, b.`kürzel`, b.name, b.vorname' +
' ORDER BY b.name ASC',
[ab + ' 00:00:00']
) as FahrkostenRow[];
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/fahrkosten:', error); console.error('GET /api/fahrkosten:', error);
+22 -71
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch'; import { triggerBackup } from '@/lib/backup';
import * as phpdb from '@/lib/phpdb';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession(); const session = await getSession();
@@ -11,65 +11,25 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[];
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Ändern dieses Eintrags' }, { status: 403 });
}
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await getPool().execute( await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
'UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, SonderName=?, Beginn=?, Ende=?, Besucher=?,' + Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
' Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? WHERE ID=?', Besucher: Besucher ?? 0,
[ beoIds: beoIds ?? [],
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende, objekte: objekte ?? [],
Besucher ?? 0, Bemerkungen: Bemerkungen ?? null,
Bemerkungen?.slice(0, 500) || null, Wetter: Wetter ?? null,
Wetter?.temp ?? null, });
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
logbuchId,
]
);
await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]);
await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]);
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await getPool().execute(
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId]
);
}
triggerBackup();
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
if (error.message.includes('403')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
}
console.error('PUT /api/logbuch/[id]:', error); console.error('PUT /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
} }
@@ -83,22 +43,13 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[]; await phpdb.deleteLogbuch(logbuchId, session.beoId, session.role ?? '');
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Löschen dieses Eintrags' }, { status: 403 });
}
await query('DELETE FROM logbuch WHERE ID = ?', [logbuchId]);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
if (error.message.includes('403')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
}
console.error('DELETE /api/logbuch/[id]:', error); console.error('DELETE /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
} }
+17 -111
View File
@@ -1,89 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch'; import { triggerBackup } from '@/lib/backup';
import * as phpdb from '@/lib/phpdb';
const LIST_SQL =
'SELECT' +
' l.ID, l.Kuppel, l.ArtFuehrung,' +
" DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn," +
" DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende," +
' l.Besucher, l.Bemerkungen, l.SonderName,' +
' l.WetterTemp, l.WetterFeuchte, l.WetterDruck,' +
' l.created_by, l.created_at,' +
' creator.kuerzel AS created_by_kuerzel,' +
" GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," +
" GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') AS Objekte" +
' FROM logbuch l' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) creator ON creator.id = l.created_by' +
' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' +
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
' WHERE l.Kuppel = ?' +
' GROUP BY l.ID';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West'; const kuppel = searchParams.get('kuppel') || 'West';
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500); const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500);
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0); const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
const month = searchParams.get('month') || ''; const month = searchParams.get('month') || '';
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC'; const order = searchParams.get('order') === 'asc' ? 'asc' : 'desc';
const search = (searchParams.get('search') || '').trim(); const search = (searchParams.get('search') || '').trim();
if (search) {
const pattern = '%' + search + '%';
const searchParams2 = [kuppel, pattern, pattern, pattern];
const countSQL =
'SELECT COUNT(*) AS total FROM (' +
'SELECT l.ID FROM logbuch l' +
' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' +
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
' WHERE l.Kuppel = ?' +
' GROUP BY l.ID' +
" HAVING (MAX(l.Bemerkungen) LIKE ? OR GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') LIKE ? OR GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') LIKE ?)" +
') AS sub';
const listSQL = LIST_SQL +
' HAVING (MAX(l.Bemerkungen) LIKE ? OR BEOs LIKE ? OR Objekte LIKE ?)' +
` ORDER BY l.Beginn DESC LIMIT ${limit} OFFSET ${offset}`;
try { try {
const [countRows, entries] = await Promise.all([ const result = await phpdb.listLogbuch({ kuppel, limit, offset, month, search, order });
query(countSQL, searchParams2) as Promise<{ total: number }[]>, return NextResponse.json(result);
query(listSQL, searchParams2),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) {
console.error('GET /api/logbuch (search):', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
let listWhere = 'WHERE l.Kuppel = ?';
let countWhere = 'WHERE Kuppel = ?';
let params: (string | number | null)[] = [kuppel];
if (month && /^\d{4}-\d{2}$/.test(month)) {
const [y, m] = month.split('-').map(Number);
const start = `${y}-${String(m).padStart(2, '0')}-01`;
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
const end = `${nextY}-${String(nextM).padStart(2, '0')}-01`;
listWhere += ' AND l.Beginn >= ? AND l.Beginn < ?';
countWhere += ' AND Beginn >= ? AND Beginn < ?';
params = [kuppel, start, end];
}
try {
const [countRows, entries] = await Promise.all([
query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>,
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` ORDER BY l.Beginn ${order} LIMIT ${limit} OFFSET ${offset}`, params),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) { } catch (error) {
console.error('GET /api/logbuch:', error); console.error('GET /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -98,46 +32,18 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const pool = getPool(); const result = await phpdb.createLogbuch({
const [result] = await pool.execute( Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
'INSERT INTO logbuch (Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' + Besucher: Besucher ?? 0,
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', beoIds: beoIds ?? [],
[ objekte: objekte ?? [],
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende, Bemerkungen: Bemerkungen ?? null,
Besucher ?? 0, Wetter: Wetter ?? null,
Bemerkungen?.slice(0, 500) || null, created_by: session.beoId,
Wetter?.temp ?? null, });
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
session.beoId,
]
) as [{ insertId: number }, unknown];
const logbuchId = result.insertId; triggerBackup();
return NextResponse.json(result, { status: 201 });
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await pool.execute('INSERT INTO objekte (Name) VALUES (?)', [obj.Name]) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId]
);
}
return NextResponse.json({ id: logbuchId }, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/logbuch:', error); console.error('POST /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+7 -5
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession(); const session = await getSession();
@@ -10,11 +10,13 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
const { id } = await params; const { id } = await params;
const numId = Number(id); const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
const { name } = await req.json(); const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim(); const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 }); if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
await query('UPDATE objekte SET Name = ? WHERE ID = ?', [trimmed, numId]); const VALID = ['stern', 'sonne', 'stern,sonne'];
return NextResponse.json({ ID: numId, Name: trimmed }); const kat: string | undefined = VALID.includes(kategorie) ? kategorie : undefined;
const result = await phpdb.updateObjekt(numId, trimmed, kat);
return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error('PUT /api/objekte/[id]:', error); console.error('PUT /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -29,7 +31,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
const { id } = await params; const { id } = await params;
const numId = Number(id); const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
await query('DELETE FROM objekte WHERE ID = ?', [numId]); await phpdb.deleteObjekt(numId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('DELETE /api/objekte/[id]:', error); console.error('DELETE /api/objekte/[id]:', error);
+10 -6
View File
@@ -1,12 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET() { export async function GET(req: NextRequest) {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { try {
const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100'); const raw = req.nextUrl.searchParams.get('kategorie');
const kategorie = raw === 'sonne' ? 'sonne' : 'stern';
const rows = await phpdb.getObjekte(kategorie);
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/objekte:', error); console.error('GET /api/objekte:', error);
@@ -19,11 +21,13 @@ export async function POST(req: NextRequest) {
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }); if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try { try {
const { name } = await req.json(); const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim(); const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 }); if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const result = await query('INSERT INTO objekte (Name) VALUES (?)', [trimmed]) as { insertId: number }; const VALID = ['stern', 'sonne', 'stern,sonne'];
return NextResponse.json({ ID: result.insertId, Name: trimmed }, { status: 201 }); const kat: string = VALID.includes(kategorie) ? kategorie : 'stern';
const result = await phpdb.createObjekt(trimmed, kat);
return NextResponse.json(result, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/objekte:', error); console.error('POST /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -0,0 +1,18 @@
import { grafikProxy } from '../proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server';
const blockedHeaders = new Set([
'x-frame-options',
'content-security-policy',
'content-security-policy-report-only',
'frame-options',
'content-encoding',
'content-length',
'transfer-encoding',
]);
function buildProxyTarget(req: Request, slug?: string[]) {
const upstreamUrl = process.env.STATISTIK_GRAFIK_URL;
if (!upstreamUrl) {
throw new Error('STATISTIK_GRAFIK_URL is not configured');
}
const target = new URL(upstreamUrl);
const pathSuffix = slug?.filter(Boolean).join('/') || '';
if (pathSuffix) {
target.pathname = `${target.pathname.replace(/\/$/, '')}/${pathSuffix}`;
}
target.search = new URL(req.url).search;
return target.toString();
}
function buildAuthHeaders() {
const headers: Record<string, string> = {};
const user = process.env.STATISTIK_GRAFIK_USER;
const pass = process.env.STATISTIK_GRAFIK_PASS;
if (user && pass) {
const token = Buffer.from(`${user}:${pass}`).toString('base64');
headers.Authorization = `Basic ${token}`;
}
return headers;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function rewriteHtml(body: string) {
const proxyBase = '/api/statistik/grafik';
const upstreamOrigin = new URL(process.env.STATISTIK_GRAFIK_URL!).origin;
const escapedProxyBase = escapeRegExp(proxyBase);
const proxyBasePattern = escapedProxyBase.replace(/\//g, '\\/');
return body
.replace(/<meta[^>]*?(?:http-equiv|httpEquiv)\s*=\s*['"]?[^'">\s]+['"]?[^>]*>/ig, (m) => {
if (/x-frame-options/i.test(m)) return '';
if (/content-security-policy/i.test(m)) return '';
if (/frame-ancestors/i.test(m)) return '';
return m;
})
.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBase}/">`)
.replace(new RegExp(`(href|src|action)=(["'])(?!${proxyBasePattern}\\/)\/`, 'gi'), `$1=$2${proxyBase}/`)
.replace(new RegExp(`url\\((['"]?)(?!${proxyBasePattern}\\/)\/`, 'gi'), `url($1${proxyBase}/`)
.replace(new RegExp(escapeRegExp(upstreamOrigin), 'g'), proxyBase);
}
export async function grafikProxy(req: Request, slug?: string[]) {
let targetUrl: string;
try {
targetUrl = buildProxyTarget(req, slug);
} catch (error) {
return new NextResponse((error as Error).message, { status: 500 });
}
try {
const requestHeaders: Record<string, string> = {};
req.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'host' || lower === 'content-length') return;
requestHeaders[key] = value;
});
Object.assign(requestHeaders, buildAuthHeaders());
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer();
const upstream = await fetch(targetUrl, {
method: req.method,
headers: requestHeaders,
body,
cache: 'no-store',
});
const bodyBuf = await upstream.arrayBuffer();
const outHeaders: Record<string, string> = {};
upstream.headers.forEach((value, key) => {
if (!blockedHeaders.has(key.toLowerCase())) {
outHeaders[key] = value;
}
});
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
const isHtmlResponse = contentType.toLowerCase().includes('text/html');
// JS/CSS/font assets that come back as HTML are upstream errors (404, redirect pages).
// Return a 404 so the browser doesn't refuse to execute them as wrong MIME type.
const assetExtension = /\.(js|css|woff2?|ttf|eot|png|jpg|gif|svg|ico)(\?|$)/i;
const requestPath = new URL(req.url).pathname;
if (isHtmlResponse && slug?.length && assetExtension.test(requestPath)) {
return new NextResponse(null, { status: 404 });
}
if (isHtmlResponse) {
const text = Buffer.from(bodyBuf).toString('utf8');
const cleaned = rewriteHtml(text);
outHeaders['content-type'] = 'text/html; charset=utf-8';
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(cleaned, { status: upstream.status, headers: outHeaders });
}
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(Buffer.from(bodyBuf), { status: upstream.status, headers: outHeaders });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new NextResponse(`grafikProxy error: ${message}`, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { grafikProxy } from './proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
+3 -54
View File
@@ -1,68 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West';
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10); const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10);
try { try {
// 1) Monatliche Besucherzahlen nach ArtFuehrung const result = await phpdb.getStatistik(year);
const monthlyRows = await query( return NextResponse.json(result);
`SELECT
MONTH(Beginn) AS monat,
ArtFuehrung,
SUM(Besucher) AS besucher,
COUNT(*) AS anzahl
FROM logbuch
WHERE Kuppel = ? AND YEAR(Beginn) = ?
GROUP BY MONTH(Beginn), ArtFuehrung
ORDER BY monat, ArtFuehrung`,
[kuppel, year]
) as { monat: number; ArtFuehrung: string; besucher: number; anzahl: number }[];
// 2) Kumulierte Besucher im Jahr
const cumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { total: number | null }[];
// 3) Anzahl Führungstage (distinct Datum)
const tageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { tage: number }[];
// 4) Kumulierte Besucher über alle Kuppeln
const allCumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { total: number | null }[];
// 5) Führungstage über alle Kuppeln
const allTageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { tage: number }[];
return NextResponse.json({
monthly: monthlyRows.map((r) => ({
monat: Number(r.monat),
ArtFuehrung: r.ArtFuehrung,
besucher: Number(r.besucher),
anzahl: Number(r.anzahl),
})),
cumulative: Number(cumulativeRows[0]?.total ?? 0),
tage: Number(tageRows[0]?.tage ?? 0),
allCumulative: Number(allCumulativeRows[0]?.total ?? 0),
allTage: Number(allTageRows[0]?.tage ?? 0),
year,
kuppel,
});
} catch (error) { } catch (error) {
console.error('GET /api/statistik:', error); console.error('GET /api/statistik:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+2 -5
View File
@@ -3,7 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getSession, createSession } from '@/lib/session'; import { getSession, createSession } from '@/lib/session';
import { hashPassword } from '@/lib/auth'; import { hashPassword } from '@/lib/auth';
import { query } from '@/lib/db'; import { updateBeoPassword } from '@/lib/phpdb';
export async function changePassword( export async function changePassword(
_prevState: { error: string } | undefined, _prevState: { error: string } | undefined,
@@ -28,10 +28,7 @@ export async function changePassword(
} }
const hashed = await hashPassword(newPassword); const hashed = await hashPassword(newPassword);
await query( await updateBeoPassword(session.beoId, hashed);
'UPDATE beos SET pw = ?, MustChangePassword = 0 WHERE id = ?',
[hashed, session.beoId]
);
await createSession({ await createSession({
kuerzel: session.kuerzel, kuerzel: session.kuerzel,
+1 -1
View File
@@ -11,7 +11,7 @@ export default function ChangePasswordPage() {
return ( return (
<div className="min-h-screen bg-white py-4 px-4"> <div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<h1 className="text-3xl font-bold mb-6">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold mb-6">Führungsbuch Sternwarte Welzheim</h1>
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8"> <div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Logbuch — Sternwarte Welzheim', title: 'Führungsbuch — Sternwarte Welzheim',
description: 'Logbuch für die Sternwarte Welzheim', description: 'Führungsbuch für die Sternwarte Welzheim',
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
+1 -1
View File
@@ -17,7 +17,7 @@ export default function LoginPage() {
<div className="min-h-screen bg-white py-4 px-4"> <div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
</div> </div>
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
Executable
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
# Tägliches MySQL-Backup für das Logbuch-Projekt.
#
# Verwendung:
# ./backup_db.sh
# DB_ROOT_PASS=... DB_NAME=... ./backup_db.sh
# ./backup_db.sh --days 14 --backup-dir /var/backups/logbuch
#
# Installiert keine Abhängigkeiten. Nutzt den laufenden Container mit dem Namen logbuch_mysql.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR_DEFAULT="$SCRIPT_DIR/DB_BACKUP"
CONTAINER_DEFAULT="logbuch_mysql"
KEEP_DAYS_DEFAULT=7
ROOT_PASS="${DB_ROOT_PASS:-${MYSQL_ROOT_PASSWORD:-}}"
DB_NAME="${DB_NAME:-${MYSQL_DATABASE:-}}"
BACKUP_DIR="$BACKUP_DIR_DEFAULT"
CONTAINER="$CONTAINER_DEFAULT"
KEEP_DAYS="$KEEP_DAYS_DEFAULT"
REMOTE=""
REMOTE_RETENTION=""
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--backup-dir DIR Backup-Verzeichnis (default: $BACKUP_DIR_DEFAULT)
--container NAME MySQL-Container-Name (default: $CONTAINER_DEFAULT)
--days N Aufbewahrungszeitraum in Tagen (default: $KEEP_DAYS_DEFAULT)
--db-name NAME Datenbankname (default: DB_NAME oder MYSQL_DATABASE env)
--root-pass PASS MySQL root Passwort (default: DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD env)
--remote user@host:/path Optional: kopiere Backup per scp auf Remote-Host
--remote-retention N Optional: Aufbewahrungstage auf Remote (default: same as --days)
-h, --help Diese Hilfe anzeigen
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backup-dir)
BACKUP_DIR="$2"
shift 2
;;
--container)
CONTAINER="$2"
shift 2
;;
--days)
KEEP_DAYS="$2"
shift 2
;;
--db-name)
DB_NAME="$2"
shift 2
;;
--root-pass)
ROOT_PASS="$2"
shift 2
;;
--remote)
REMOTE="$2"
shift 2
;;
--remote-retention)
REMOTE_RETENTION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unbekannte Option: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$ROOT_PASS" ]]; then
echo "Fehler: MySQL root Passwort ist nicht gesetzt. Setze DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD." >&2
exit 1
fi
if [[ -z "$DB_NAME" ]]; then
echo "Fehler: Datenbankname ist nicht gesetzt. Setze DB_NAME oder MYSQL_DATABASE." >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_$TIMESTAMP.sql.gz"
echo "Erstelle Backup: $BACKUP_FILE"
docker exec "$CONTAINER" mysqldump --single-transaction --quick --lock-tables=false \
--triggers --routines --events --set-gtid-purged=OFF -uroot -p"$ROOT_PASS" "$DB_NAME" | gzip > "$BACKUP_FILE"
if [[ ! -s "$BACKUP_FILE" ]]; then
echo "Fehler: Backup konnte nicht erstellt werden." >&2
exit 1
fi
echo "Backup erfolgreich erstellt: $BACKUP_FILE"
# Optional: upload to remote host (format: user@host:/path)
if [[ -n "$REMOTE" ]]; then
echo "Upload nach Remote: $REMOTE"
if [[ -z "$REMOTE_RETENTION" ]]; then
REMOTE_RETENTION="$KEEP_DAYS"
fi
if [[ "$REMOTE" == *":"* ]]; then
REMOTE_HOSTPART="${REMOTE%%:*}"
REMOTE_PATH="${REMOTE#*:}"
if [[ -z "$REMOTE_PATH" ]]; then
REMOTE_PATH="."
fi
# Verzeichnis auf Remote anlegen, dann Datei kopieren
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "mkdir -p \"$REMOTE_PATH\""
if ! scp -q "$BACKUP_FILE" "${REMOTE_HOSTPART}:${REMOTE_PATH}/"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
echo "Auf Remote $REMOTE_HOSTPART: entferne Backups älter als $REMOTE_RETENTION Tage in $REMOTE_PATH"
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "find \"$REMOTE_PATH\" -maxdepth 1 -type f -name '${DB_NAME}_*.sql.gz' -mtime +$REMOTE_RETENTION -print -delete" || true
fi
else
if ! scp -q "$BACKUP_FILE" "$REMOTE"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
fi
fi
fi
echo "Lösche Backups älter als $KEEP_DAYS Tage im Verzeichnis $BACKUP_DIR"
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${DB_NAME}_*.sql.gz" -mtime +"$KEEP_DAYS" -print -delete || true
echo "Fertig."
+2 -2
View File
@@ -1,5 +1,5 @@
Erstelle ein Logbuch für die Sternwarte in Welzheim. Erstelle ein Führungsbuch für die Sternwarte in Welzheim.
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Logbuch. Alle 4 Logbücher sind identisch gebaut. + Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Führungsbuch. Alle 4 Logbücher sind identisch gebaut.
+ Als Datenbank soll eine MYSQL verwendet werden + Als Datenbank soll eine MYSQL verwendet werden
+ Als Sprache soll nextjs zum Einsatz kommen + Als Sprache soll nextjs zum Einsatz kommen
+ Der Zugang zu der Webseite soll per User/Passwort gesichert werden + Der Zugang zu der Webseite soll per User/Passwort gesichert werden
+241
View File
@@ -0,0 +1,241 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
interface Props {
value: string; // "YYYY-MM-DD"
onChange: (value: string) => void;
className?: string;
}
const MONTH_NAMES = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
const WEEKDAY_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function monthKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}
function monthLabel(ym: string): string {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
return `${MONTH_NAMES[m - 1]} ${y}`;
}
function shiftMonth(ym: string, delta: number): string {
const [ys, ms] = ym.split('-');
const d = new Date(Number(ys), Number(ms) - 1 + delta, 1);
return monthKey(d);
}
function parseISODate(v: string): Date | null {
if (!isValidDateString(v)) return null;
const [ys, ms, ds] = v.split('-');
return new Date(Number(ys), Number(ms) - 1, Number(ds));
}
function buildMonthGrid(ym: string): Array<number | null> {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
const first = new Date(y, m - 1, 1);
const days = new Date(y, m, 0).getDate();
const mondayStartOffset = (first.getDay() + 6) % 7;
const cells: Array<number | null> = Array(mondayStartOffset).fill(null);
for (let d = 1; d <= days; d += 1) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
function isValidDateString(v: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
const [ys, ms, ds] = v.split('-');
const y = Number(ys);
const m = Number(ms);
const d = Number(ds);
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
function normalize(v: string): string {
const digits = v.replace(/\D/g, '').slice(0, 8);
const y = digits.slice(0, 4);
const m = digits.slice(4, 6);
const d = digits.slice(6, 8);
if (digits.length <= 4) return y;
if (digits.length <= 6) return `${y}-${m}`;
return `${y}-${m}-${d}`;
}
export default function DateInput({ value, onChange, className = '' }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
const [open, setOpen] = useState(false);
const [viewMonth, setViewMonth] = useState(monthKey(new Date()));
const wrapperRef = useRef<HTMLDivElement>(null);
const selectedDate = useMemo(() => parseISODate(value), [value]);
const dayCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
useEffect(() => {
setLocal(value);
setError(false);
}, [value]);
useEffect(() => {
const date = parseISODate(value);
if (date) setViewMonth(monthKey(date));
}, [value]);
useEffect(() => {
function onDocMouseDown(ev: MouseEvent) {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(ev.target as Node)) setOpen(false);
}
function onDocKeyDown(ev: KeyboardEvent) {
if (ev.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onDocKeyDown);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onDocKeyDown);
};
}, []);
function handleChange(raw: string) {
setError(false);
setLocal(normalize(raw));
}
function handleBlur() {
if (local === '') {
setLocal(value);
setError(false);
return;
}
if (isValidDateString(local)) {
setError(false);
onChange(local);
return;
}
setError(true);
}
function selectDay(day: number) {
const [ys, ms] = viewMonth.split('-');
const iso = `${ys}-${ms}-${pad(day)}`;
setLocal(iso);
setError(false);
onChange(iso);
setOpen(false);
}
function isSelected(day: number): boolean {
if (!selectedDate) return false;
const [ys, ms] = viewMonth.split('-').map(Number);
return selectedDate.getFullYear() === ys && selectedDate.getMonth() === ms - 1 && selectedDate.getDate() === day;
}
return (
<div ref={wrapperRef} className={`relative ${className}`}>
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
value={local}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder="YYYY-MM-DD"
maxLength={10}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
}`}
/>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-xs text-gray-700 hover:bg-gray-50"
aria-label="Kalender oeffnen"
>
Kalender
</button>
</div>
{open && (
<div className="absolute left-0 top-full mt-1 z-20 w-64 border-2 border-gray-300 rounded-lg bg-white shadow-lg p-2">
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, -1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Vorheriger Monat"
>
{'<'}
</button>
<div className="text-sm font-medium">{monthLabel(viewMonth)}</div>
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, 1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Naechster Monat"
>
{'>'}
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAY_SHORT.map((wd) => (
<div key={wd} className="text-[11px] text-center text-gray-500 py-1">
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{dayCells.map((day, idx) => (
<button
key={`${viewMonth}-${idx}`}
type="button"
disabled={day === null}
onClick={() => day !== null && selectDay(day)}
className={`h-8 rounded text-sm ${
day === null
? 'text-transparent cursor-default'
: isSelected(day)
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-gray-100 text-gray-800'
}`}
>
{day ?? '-'}
</button>
))}
</div>
</div>
)}
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungueltiges Datum (YYYY-MM-DD)
</p>
)}
</div>
);
}
+4 -4
View File
@@ -35,11 +35,11 @@ export default function Fahrkosten() {
const gesamt = rows ? rows.reduce((s, r) => s + r.Anzahl * SATZ, 0) : 0; const gesamt = rows ? rows.reduce((s, r) => s + r.Anzahl * SATZ, 0) : 0;
const thCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-left whitespace-nowrap'; const thCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-left whitespace-nowrap text-gray-900';
const thNarrowCls = `${thCls} w-16`; const thNarrowCls = `${thCls} w-16`;
const tdCls = 'px-3 py-2 border border-gray-200 text-sm'; const tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const tdNarrowCls = `${tdCls} w-16`; const tdNarrowCls = `${tdCls} w-16`;
const tdNumCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums w-20'; const tdNumCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums w-20 text-gray-900';
const abFormatted = new Date(ab + 'T00:00:00').toLocaleDateString('de-DE', { const abFormatted = new Date(ab + 'T00:00:00').toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
@@ -53,7 +53,7 @@ export default function Fahrkosten() {
type="date" type="date"
value={ab} value={ab}
onChange={(e) => setAb(e.target.value)} onChange={(e) => setAb(e.target.value)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
+18 -20
View File
@@ -120,8 +120,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {}); .catch(() => {});
} }
if (editEntry && editEntry.Objekte) { if (editEntry && editEntry.Objekte) {
const names = editEntry.Objekte.split(', ').map((n) => n.trim()); const allNames = editEntry.Objekte.split(', ').map((n) => n.trim());
fetch('/api/objekte') const isSonneEntry = editEntry.ArtFuehrung === SONNE_ART;
const names = isSonneEntry ? allNames.filter((n) => n.toLowerCase() !== 'sonne') : allNames;
const kat = isSonneEntry ? 'sonne' : 'stern';
fetch('/api/objekte?kategorie=' + kat)
.then((r) => r.json()) .then((r) => r.json())
.then((all: { ID: number; Name: string }[]) => { .then((all: { ID: number; Name: string }[]) => {
const result: SelectedObjekt[] = names.map((name) => { const result: SelectedObjekt[] = names.map((name) => {
@@ -134,12 +137,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
} }
}, [editEntry]); }, [editEntry]);
// Objekte-Vorauswahl je nach Art der Führung // Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung
useEffect(() => { useEffect(() => {
if (artFuehrung === SONNE_ART) {
setObjekte([{ ID: null, Name: 'Sonne' }]);
} else if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setObjekte([]); setObjekte([]);
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setBesucher('');
} }
}, [artFuehrung]); }, [artFuehrung]);
@@ -171,7 +173,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
Ende: ende, Ende: ende,
Besucher: besucher === '' ? 0 : besucher, Besucher: besucher === '' ? 0 : besucher,
beoIds: beos.map((b) => b.ID), beoIds: beos.map((b) => b.ID),
objekte: showObjekte ? objekte : [], objekte: showObjekte ? (isSonne ? [{ ID: null, Name: 'Sonne' }, ...objekte] : objekte) : [],
Bemerkungen: bemerkungen, Bemerkungen: bemerkungen,
Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 }, Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 },
}; };
@@ -204,7 +206,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
} }
} }
const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none'; const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none';
const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5'; const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5';
return ( return (
@@ -272,7 +274,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)} onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)}
min={0} min={0}
max={9999} max={9999}
className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
)} )}
@@ -303,16 +305,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{showObjekte && ( {showObjekte && (
<div> <div>
<label className={labelCls}>Beobachtete Objekte</label> <label className={labelCls}>Beobachtete Objekte</label>
{isSonne ? ( <ObjektSelector
<div className="flex items-center gap-2"> selected={objekte}
<span className="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1.5 rounded-full"> onChange={setObjekte}
Sonne kategorie={isSonne ? 'sonne' : 'stern'}
</span> fixedItems={isSonne ? [{ ID: null, Name: 'Sonne' }] : []}
<span className="text-xs text-gray-500">(bei Sonnenführung fest vorgegeben)</span> />
</div>
) : (
<ObjektSelector selected={objekte} onChange={setObjekte} />
)}
</div> </div>
)} )}
@@ -326,7 +324,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
value={bemerkungen} value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))} onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={2} rows={2}
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none resize-y" className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none resize-y"
placeholder="Freier Text (max. 500 Zeichen)" placeholder="Freier Text (max. 500 Zeichen)"
/> />
</div> </div>
+8 -8
View File
@@ -148,7 +148,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
value={month} value={month}
max={currentMonth()} max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)} onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]" className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/> />
<button <button
onClick={() => setMonth((m) => nextMonth(m))} onClick={() => setMonth((m) => nextMonth(m))}
@@ -168,7 +168,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Suche in Bemerkungen, Objekte, BEOs…" placeholder="Suche in Bemerkungen, Objekte, BEOs…"
className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]" className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/> />
{search ? ( {search ? (
<button <button
@@ -194,11 +194,11 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
); );
const cell = compact const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs' ? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
: 'px-3 py-2 border border-gray-200'; : 'px-3 py-2 border border-gray-200 text-gray-900';
const head = compact const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold' ? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
: 'px-3 py-2 border border-gray-300'; : 'px-3 py-2 border border-gray-300 text-gray-900';
const displayEntries = printEntries ?? entries; const displayEntries = printEntries ?? entries;
@@ -306,13 +306,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
<button <button
onClick={() => setPageState({ page: page - 1, key: filterKey })} onClick={() => setPageState({ page: page - 1, key: filterKey })}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
> Zurück</button> > Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span> <span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button <button
onClick={() => setPageState({ page: page + 1, key: filterKey })} onClick={() => setPageState({ page: page + 1, key: filterKey })}
disabled={(page + 1) * limit >= total} disabled={(page + 1) * limit >= total}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
>Weiter </button> >Weiter </button>
</div> </div>
)} )}
+19 -6
View File
@@ -6,9 +6,11 @@ import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
interface Props { interface Props {
selected: SelectedObjekt[]; selected: SelectedObjekt[];
onChange: (objekte: SelectedObjekt[]) => void; onChange: (objekte: SelectedObjekt[]) => void;
kategorie?: 'stern' | 'sonne';
fixedItems?: SelectedObjekt[];
} }
export default function ObjektSelector({ selected, onChange }: Props) { export default function ObjektSelector({ selected, onChange, kategorie = 'stern', fixedItems = [] }: Props) {
const [all, setAll] = useState<ObjektOption[]>([]); const [all, setAll] = useState<ObjektOption[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -16,11 +18,11 @@ export default function ObjektSelector({ selected, onChange }: Props) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
fetch('/api/objekte') fetch('/api/objekte?kategorie=' + kategorie)
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); }) .then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
.then(setAll) .then(setAll)
.catch(() => {}); .catch(() => {});
}, []); }, [kategorie]);
useEffect(() => { useEffect(() => {
function handleOutside(e: MouseEvent) { function handleOutside(e: MouseEvent) {
@@ -32,14 +34,17 @@ export default function ObjektSelector({ selected, onChange }: Props) {
return () => document.removeEventListener('mousedown', handleOutside); return () => document.removeEventListener('mousedown', handleOutside);
}, [dropdownOpen]); }, [dropdownOpen]);
const fixedNamesLower = new Set(fixedItems.map((o) => o.Name.toLowerCase()));
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase())); const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase())); const available = all.filter(
(o) => !selectedNames.has(o.Name.toLowerCase()) && !fixedNamesLower.has(o.Name.toLowerCase())
);
const filtered = search const filtered = search
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase())) ? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
: available; : available;
const searchTrimmed = search.trim(); const searchTrimmed = search.trim();
const alreadySelected = searchTrimmed !== '' && selectedNames.has(searchTrimmed.toLowerCase()); const alreadySelected = searchTrimmed !== '' && (selectedNames.has(searchTrimmed.toLowerCase()) || fixedNamesLower.has(searchTrimmed.toLowerCase()));
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase()); const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase());
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch; const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
@@ -51,7 +56,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
function addNew(name: string) { function addNew(name: string) {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed || selectedNames.has(trimmed.toLowerCase())) return; if (!trimmed || selectedNames.has(trimmed.toLowerCase()) || fixedNamesLower.has(trimmed.toLowerCase())) return;
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase()); const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase());
if (existing) { if (existing) {
onChange([...selected, { ID: existing.ID, Name: existing.Name }]); onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
@@ -79,6 +84,14 @@ export default function ObjektSelector({ selected, onChange }: Props) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{fixedItems.map((o) => (
<span
key={'fixed-' + o.Name}
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full"
>
{o.Name}
</span>
))}
{selected.map((o) => ( {selected.map((o) => (
<span <span
key={o.Name} key={o.Name}
+100 -103
View File
@@ -1,24 +1,29 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ArtFuehrung, Kuppel } from '@/types/logbuch';
import { artLabel } from '@/types/logbuch';
interface MonthlyRow { interface MonthRow {
monat: number; monat: number;
ArtFuehrung: string; tageFuehrungen: number;
besucher: number; tageBeob: number;
anzahl: number; tageTD: number;
tageSonst: number;
tageBEOS: number;
tagesToT: number;
tageGesamt: number;
besucherRF: number;
besucherSF: number;
besucherSonF: number;
besucherPrF: number;
besucherToT: number;
besucherGesamt: number;
} }
interface StatsData { interface StatsData {
monthly: MonthlyRow[]; monthly: MonthRow[];
cumulative: number; cumulative: number;
tage: number; tage: number;
allCumulative: number;
allTage: number;
year: number; year: number;
kuppel: Kuppel;
} }
const MONATE = [ const MONATE = [
@@ -26,73 +31,44 @@ const MONATE = [
'Juli','August','September','Oktober','November','Dezember', 'Juli','August','September','Oktober','November','Dezember',
]; ];
interface Props { function n(v: number) {
kuppel: Kuppel; return v > 0 ? v.toLocaleString('de-DE') : '';
} }
export default function Statistik({ kuppel }: Props) { export default function Statistik() {
const [year, setYear] = useState(new Date().getFullYear()); const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<StatsData | null>(null); const [data, setData] = useState<StatsData | null>(null);
const [fetchError, setFetchError] = useState<{ year: number; kuppel: Kuppel } | null>(null); const [fetchError, setFetchError] = useState<number | null>(null);
const error = fetchError?.year === year && fetchError?.kuppel === kuppel const error = fetchError === year ? 'Fehler beim Laden der Statistik.' : '';
? 'Fehler beim Laden der Statistik.' const loading = !error && (!data || data.year !== year);
: '';
const loading = !error && (!data || data.year !== year || data.kuppel !== kuppel);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetch(`/api/statistik?kuppel=${encodeURIComponent(kuppel)}&year=${year}`) fetch(`/api/statistik?year=${year}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: StatsData) => { if (!cancelled) { setData(d); setFetchError(null); } }) .then((d: StatsData) => { if (!cancelled) { setData(d); setFetchError(null); } })
.catch(() => { if (!cancelled) setFetchError({ year, kuppel }); }); .catch(() => { if (!cancelled) setFetchError(year); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [kuppel, year]); }, [year]);
const { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal } = useMemo(() => {
if (!data) {
return { arten: [] as string[], matrix: [] as (number | null)[][], monatTotal: [] as number[], artTotal: [] as number[], grandTotal: 0, anzahlTotal: [] as number[] };
}
const artenSet = new Set<string>();
data.monthly.forEach((r) => artenSet.add(r.ArtFuehrung));
const arten = Array.from(artenSet).sort();
const matrix: (number | null)[][] = [];
const monatTotal: number[] = [];
const anzahlTotal: number[] = [];
const artTotal: number[] = new Array(arten.length).fill(0);
let grandTotal = 0;
for (let m = 1; m <= 12; m++) {
const row: (number | null)[] = [];
let mSum = 0;
let aSum = 0;
arten.forEach((art, idx) => {
const found = data.monthly.find((r) => r.monat === m && r.ArtFuehrung === art);
const val = found ? found.besucher : null;
row.push(val);
if (val !== null) {
mSum += val;
artTotal[idx] += val;
aSum += found!.anzahl;
}
});
matrix.push(row);
monatTotal.push(mSum);
anzahlTotal.push(aSum);
grandTotal += mSum;
}
return { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal };
}, [data]);
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Statistik...</div>; if (loading) return <div className="text-gray-500 text-sm py-4">Lade Statistik...</div>;
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>; if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
const headCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 whitespace-nowrap'; const rows = data!.monthly;
const cellCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums';
const labelCls = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap'; function col(key: keyof MonthRow): number {
return rows.reduce((s, r) => s + (r[key] as number), 0);
}
const thTop = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-center text-gray-900';
const thSub = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const thDiv = 'px-3 py-2 border border-gray-300 border-l-4 border-l-gray-400 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const td = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums text-gray-900';
const tdDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums text-gray-900';
const tdL = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap text-gray-900';
const tdSum = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
const tdSumDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -104,7 +80,7 @@ export default function Statistik({ kuppel }: Props) {
onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())} onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
min={2000} min={2000}
max={2100} max={2100}
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
@@ -112,62 +88,83 @@ export default function Statistik({ kuppel }: Props) {
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className={headCls}>Monat</th> <th className={thTop} rowSpan={2}>Monat</th>
<th className={headCls}>Führungen</th> <th className={thTop} colSpan={6}>Besucher</th>
{arten.map((art) => ( <th className={`${thTop} border-l-4 border-l-gray-400`} colSpan={7}>Anzahl</th>
<th key={art} className={headCls}>{artLabel(art as ArtFuehrung)}</th> </tr>
))} <tr>
<th className={headCls}>Gesamt</th> <th className={thSub}>RF</th>
<th className={thSub}>SF</th>
<th className={thSub}>SonF</th>
<th className={thSub}>PrF</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
<th className={thDiv}>Führ.</th>
<th className={thSub}>Beob.</th>
<th className={thSub}>TD</th>
<th className={thSub}>Sonst.</th>
<th className={thSub}>BEOS</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{MONATE.map((name, idx) => { {MONATE.map((name, idx) => {
const mSum = monatTotal[idx]; const m = idx + 1;
const aSum = anzahlTotal[idx]; const r = rows.find((row) => row.monat === m);
const hasData = r && r.tageGesamt > 0;
const cls = hasData ? '' : 'text-gray-400';
return ( return (
<tr key={name} className={mSum > 0 ? '' : 'text-gray-400'}> <tr key={name} className={cls}>
<td className={labelCls}>{name}</td> <td className={tdL}>{name}</td>
<td className={cellCls}>{aSum > 0 ? aSum : ''}</td> <td className={td}>{r ? n(r.besucherRF) : ''}</td>
{arten.map((_, aIdx) => { <td className={td}>{r ? n(r.besucherSF) : ''}</td>
const val = matrix[idx][aIdx]; <td className={td}>{r ? n(r.besucherSonF) : ''}</td>
return ( <td className={td}>{r ? n(r.besucherPrF) : ''}</td>
<td key={aIdx} className={cellCls}> <td className={td}>{r ? n(r.besucherToT) : ''}</td>
{val !== null && val > 0 ? val.toLocaleString('de-DE') : ''} <td className={`${td} font-semibold`}>{r ? n(r.besucherGesamt) : ''}</td>
</td> <td className={`${tdDiv} font-semibold`}>{r ? n(r.tageFuehrungen) : ''}</td>
); <td className={td}>{r ? n(r.tageBeob) : ''}</td>
})} <td className={td}>{r ? n(r.tageTD) : ''}</td>
<td className={`${cellCls} font-semibold`}>{mSum > 0 ? mSum.toLocaleString('de-DE') : ''}</td> <td className={td}>{r ? n(r.tageSonst) : ''}</td>
<td className={td}>{r ? n(r.tageBEOS) : ''}</td>
<td className={td}>{r ? n(r.tagesToT) : ''}</td>
<td className={`${td} font-semibold`}>{r ? n(r.tageGesamt) : ''}</td>
</tr> </tr>
); );
})} })}
<tr className="bg-gray-50 font-semibold"> <tr>
<td className={labelCls}>Summe</td> <td className={tdL + ' font-semibold'}>Summe</td>
<td className={cellCls}>{anzahlTotal.reduce((s, v) => s + v, 0) > 0 ? anzahlTotal.reduce((s, v) => s + v, 0).toLocaleString('de-DE') : ''}</td> <td className={tdSum}>{n(col('besucherRF'))}</td>
{artTotal.map((t, i) => ( <td className={tdSum}>{n(col('besucherSF'))}</td>
<td key={i} className={cellCls}>{t > 0 ? t.toLocaleString('de-DE') : ''}</td> <td className={tdSum}>{n(col('besucherSonF'))}</td>
))} <td className={tdSum}>{n(col('besucherPrF'))}</td>
<td className={cellCls}>{grandTotal > 0 ? grandTotal.toLocaleString('de-DE') : ''}</td> <td className={tdSum}>{n(col('besucherToT'))}</td>
<td className={tdSum}>{n(col('besucherGesamt'))}</td>
<td className={tdSumDiv}>{n(col('tageFuehrungen'))}</td>
<td className={tdSum}>{n(col('tageBeob'))}</td>
<td className={tdSum}>{n(col('tageTD'))}</td>
<td className={tdSum}>{n(col('tageSonst'))}</td>
<td className={tdSum}>{n(col('tageBEOS'))}</td>
<td className={tdSum}>{n(col('tagesToT'))}</td>
<td className={tdSum}>{n(col('tageGesamt'))}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full"> <div className="grid grid-cols-3 gap-4 w-full max-w-2xl">
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white"> <div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Kumulierte Besucher {year} ({data?.kuppel})</div> <div className="text-xs text-gray-500 mb-1">Besucher {year}</div>
<div className="text-2xl font-bold">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div> <div className="text-2xl font-bold text-gray-900">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
</div> </div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white"> <div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Führungstage {year} ({data?.kuppel})</div> <div className="text-xs text-gray-500 mb-1">Alle Events {year}</div>
<div className="text-2xl font-bold">{data?.tage ?? 0}</div> <div className="text-2xl font-bold text-gray-900">{data?.tage ?? 0}</div>
</div> </div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50"> <div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-green-700 mb-1">Kumulierte Besucher {year} (Sternwarte gesamt)</div> <div className="text-xs text-gray-500 mb-1">Führungen {year}</div>
<div className="text-2xl font-bold text-green-800">{data?.allCumulative.toLocaleString('de-DE') ?? 0}</div> <div className="text-2xl font-bold text-gray-900">{col('tageFuehrungen').toLocaleString('de-DE')}</div>
</div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50">
<div className="text-xs text-green-700 mb-1">Führungstage {year} (Sternwarte gesamt)</div>
<div className="text-2xl font-bold text-green-800">{data?.allTage ?? 0}</div>
</div> </div>
</div> </div>
</div> </div>
+2 -68
View File
@@ -1,79 +1,14 @@
services: services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
ports:
- "127.0.0.1:3336:3306"
networks:
- proxy
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
- -uroot
- -p${DB_ROOT_PASS}
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
depends_on:
logbuch_mysql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.logbuch-pma.entrypoints=http
- traefik.http.routers.logbuch-pma.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.middlewares.logbuch-pma-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.logbuch-pma.middlewares=logbuch-pma-https-redirect
- traefik.http.routers.logbuch-pma-secure.entrypoints=https
- traefik.http.routers.logbuch-pma-secure.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.routers.logbuch-pma-secure.tls=true
- traefik.http.routers.logbuch-pma-secure.middlewares=logbuch-pma-slash,logbuch-pma-strip
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.regex=^https://logbuch\.fuerst-stuttgart\.de/myadmin$$
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.replacement=https://logbuch.fuerst-stuttgart.de/myadmin/
- traefik.http.middlewares.logbuch-pma-strip.stripprefix.prefixes=/myadmin
- traefik.http.routers.logbuch-pma-secure.service=logbuch-pma
- traefik.http.services.logbuch-pma.loadbalancer.server.port=80
networks:
- proxy
logbuch_app: logbuch_app:
image: docker.citysensor.de/logbuch:latest image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app container_name: logbuch_app
restart: unless-stopped restart: unless-stopped
environment: environment:
DB_HOST: logbuch_mysql PHP_DB_URL: ${PHP_DB_URL}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
DB_PORT: 3306
AUTH_SECRET: ${AUTH_SECRET} AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production NODE_ENV: production
ports: ports:
- 127.0.0.1:${APP_PORT:-3000}:3000 - 127.0.0.1:${APP_PORT:-3000}:3000
depends_on:
logbuch_mysql:
condition: service_healthy
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.logbuch.entrypoints=http - traefik.http.routers.logbuch.entrypoints=http
@@ -88,6 +23,7 @@ services:
networks: networks:
- proxy - proxy
- gitea-internal - gitea-internal
networks: networks:
proxy: proxy:
name: dockge_default name: dockge_default
@@ -95,5 +31,3 @@ networks:
gitea-internal: gitea-internal:
name: gitea_gitea-internal name: gitea_gitea-internal
external: true external: true
volumes:
db_data: null
+6 -49
View File
@@ -1,64 +1,21 @@
services: services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
networks:
- logbuch_net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASS}"]
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
ports:
- "127.0.0.1:${PMA_PORT:-8080}:80"
depends_on:
logbuch_mysql:
condition: service_healthy
networks:
- logbuch_net
logbuch_app: logbuch_app:
image: docker.citysensor.de/logbuch:latest image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app container_name: logbuch_app
restart: unless-stopped restart: unless-stopped
env_file: .env
environment: environment:
DB_HOST: logbuch_mysql
DB_PORT: 3306
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production NODE_ENV: production
BACKUP_SSH_KEY_PATH: /run/secrets/backup_ssh_key
volumes:
- ${BACKUP_SSH_KEY_FILE:-/dev/null}:/run/secrets/backup_ssh_key:ro
ports: ports:
- "127.0.0.1:${APP_PORT:-3000}:3000" - "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on: extra_hosts:
logbuch_mysql: - "host.docker.internal:host-gateway"
condition: service_healthy
networks: networks:
- logbuch_net - logbuch_net
networks: networks:
logbuch_net: logbuch_net:
driver: bridge driver: bridge
volumes:
db_data:
+5 -27
View File
@@ -1,34 +1,13 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { query } from './db'; import { getBeoByKuerzel, getBeoByName } from './phpdb';
export interface Beo { export type { Beo } from './phpdb';
id: number; import type { Beo } from './phpdb';
name: string;
vorname: string | null;
kürzel: string | null;
pw: string | null;
MustChangePassword: number;
role: string | null;
}
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE `kürzel` = ?',
[kuerzel]
) as Beo[];
return rows[0] ?? null;
}
export async function getBeoByLogin(login: string): Promise<Beo | null> { export async function getBeoByLogin(login: string): Promise<Beo | null> {
// First try exact Kürzel match, then case-insensitive Nachname match
const byKuerzel = await getBeoByKuerzel(login); const byKuerzel = await getBeoByKuerzel(login);
if (byKuerzel) return byKuerzel; if (byKuerzel) return byKuerzel;
return getBeoByName(login);
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE LOWER(name) = LOWER(?)',
[login]
) as Beo[];
return rows[0] ?? null;
} }
export async function verifyCredentials( export async function verifyCredentials(
@@ -41,8 +20,7 @@ export async function verifyCredentials(
if (!beo.pw) { if (!beo.pw) {
const defaultPw = process.env.DEFAULT_PASSWORD; const defaultPw = process.env.DEFAULT_PASSWORD;
if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!'); if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!');
const valid = password === defaultPw; return { beo, valid: password === defaultPw };
return { beo, valid };
} }
const valid = await bcrypt.compare(password, beo.pw); const valid = await bcrypt.compare(password, beo.pw);
+123
View File
@@ -0,0 +1,123 @@
import { createWriteStream, mkdirSync, unlinkSync } from 'fs';
import { createGzip } from 'zlib';
import { join } from 'path';
import { spawn } from 'child_process';
import { getBackupData } from './phpdb';
export function triggerBackup(): void {
setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e)));
}
async function dumpToFile(filePath: string): Promise<void> {
const { tables } = await getBackupData();
const gzip = createGzip();
const file = createWriteStream(filePath);
gzip.pipe(file);
const write = (s: string) => new Promise<void>((res, rej) =>
gzip.write(s, (e) => e ? rej(e) : res())
);
const now = new Date().toISOString();
await write(`-- Führungsbuch Backup ${now}\n-- Logbuch-Tabellen\n\nSET FOREIGN_KEY_CHECKS=0;\n\n`);
for (const { name, createSql, rows } of tables) {
await write(`DROP TABLE IF EXISTS \`${name}\`;\n`);
await write(`${createSql};\n\n`);
if (rows.length > 0) {
const cols = Object.keys(rows[0]).map((c) => `\`${c}\``).join(', ');
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
const batch = rows.slice(i, i + batchSize);
const values = batch.map((row) =>
'(' + Object.values(row).map((v) => {
if (v === null) return 'NULL';
if (typeof v === 'number') return String(v);
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}).join(', ') + ')'
).join(',\n ');
await write(`INSERT INTO \`${name}\` (${cols}) VALUES\n ${values};\n`);
}
await write('\n');
}
}
await write('SET FOREIGN_KEY_CHECKS=1;\n');
await new Promise<void>((resolve, reject) => {
gzip.end();
file.on('close', resolve);
file.on('error', reject);
});
}
async function runBackup(): Promise<void> {
const sshUrl = process.env.BACKUP_SSH_URL || '';
const localDir = process.env.BACKUP_LOCAL_DIR || '/tmp/logbuch-backup';
const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19);
const filename = `sternwarte_${ts}.sql.gz`;
mkdirSync(localDir, { recursive: true });
const localPath = join(localDir, filename);
await dumpToFile(localPath);
console.log(`[backup] Dump geschrieben: ${localPath}`);
try {
if (!sshUrl) return;
const match = sshUrl.match(/^([^:]+):(.+)$/);
if (!match) {
console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben');
return;
}
const [, sshHost, remotePath] = match;
const rawKeyPath = process.env.BACKUP_SSH_KEY_PATH || '';
const keyPath = rawKeyPath.startsWith('~')
? rawKeyPath.replace('~', process.env.HOME || '/root')
: rawKeyPath;
const sshOpts = [
...(keyPath ? ['-i', keyPath] : []),
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15',
];
await new Promise<void>((resolve, reject) => {
const ssh = spawn('ssh', [...sshOpts, sshHost, `mkdir -p ${remotePath}`]);
ssh.on('error', reject);
ssh.on('close', (code) => code === 0 ? resolve() : reject(new Error(`mkdir -p exit ${code}`)));
});
await new Promise<void>((resolve, reject) => {
const scp = spawn('scp', [...sshOpts, localPath, `${sshHost}:${remotePath}/${filename}`]);
let scpErr = '';
scp.stderr.on('data', (d: Buffer) => { scpErr += d.toString(); });
scp.on('error', reject);
scp.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`))
);
});
console.log(`[backup] ${filename}${sshHost}:${remotePath}`);
await new Promise<void>((resolve) => {
const ssh = spawn('ssh', [
...sshOpts, sshHost,
`find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`,
]);
ssh.on('error', (e) => { console.error('[backup] Cleanup spawn-Fehler:', e.message); resolve(); });
ssh.on('close', (code) => {
if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')');
resolve();
});
});
} finally {
try { unlinkSync(localPath); } catch { /* bereits gelöscht oder nie angelegt */ }
}
}
-29
View File
@@ -1,29 +0,0 @@
import mysql from 'mysql2/promise';
import type { QueryResult } from 'mysql2/promise';
const dbConfig = {
host: process.env.DB_HOST || 'mydbase_mysql',
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME || 'logbuch',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
};
let pool: mysql.Pool | null = null;
export function getPool() {
if (!pool) {
pool = mysql.createPool(dbConfig);
}
return pool;
}
export async function query(sql: string, params?: (string | number | null)[]): Promise<QueryResult> {
const p = getPool();
const [rows] = await p.execute(sql, params || []);
return rows as QueryResult;
}
+185
View File
@@ -0,0 +1,185 @@
import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, Wetter } from '@/types/logbuch';
const PHP_DB_URL = process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php';
async function call<T>(cmd: string, params: object = {}): Promise<T> {
const res = await fetch(PHP_DB_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd, ...params }),
cache: 'no-store',
});
if (!res.ok) {
let detail = '';
try { detail = await res.text(); } catch { /* ignore */ }
throw new Error(`DB4js ${cmd} HTTP ${res.status}: ${detail}`);
}
const json: unknown = await res.json();
if (json && typeof json === 'object' && 'error' in json) {
throw new Error(`DB4js ${cmd}: ${(json as { error: string }).error}`);
}
return json as T;
}
// ---- Auth / Benutzer ----
export interface Beo {
id: number;
name: string;
vorname: string | null;
'kürzel': string | null;
pw: string | null;
MustChangePassword: number;
role: string | null;
}
export interface BeoUser {
id: number;
'kürzel': string | null;
name: string;
vorname: string | null;
role: string | null;
hasPw: boolean;
}
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const r = await call<{ beo: Beo | null }>('LB_AUTH_KUERZEL', { kuerzel });
return r.beo;
}
export async function getBeoByName(name: string): Promise<Beo | null> {
const r = await call<{ beo: Beo | null }>('LB_AUTH_NAME', { name });
return r.beo;
}
export async function updateBeoPassword(id: number, pwHash: string): Promise<void> {
await call('LB_UPDATE_PW', { id, pw: pwHash });
}
export async function resetBeoPassword(id: number): Promise<void> {
await call('LB_RESET_PW', { id });
}
export async function listUsers(): Promise<BeoUser[]> {
return call<BeoUser[]>('LB_LIST_USERS');
}
// ---- Logbuch ----
export interface ListLogbuchParams {
kuppel?: string;
limit?: number;
offset?: number;
month?: string;
search?: string;
order?: string;
}
export async function listLogbuch(
params: ListLogbuchParams
): Promise<{ entries: LogbuchEintrag[]; total: number }> {
return call('LB_LIST_LOGBUCH', params);
}
export interface CreateLogbuchData {
Kuppel: string;
ArtFuehrung: string;
SonderName?: string | null;
Beginn: string;
Ende: string;
Besucher?: number;
beoIds?: number[];
objekte?: SelectedObjekt[];
Bemerkungen?: string | null;
Wetter?: Partial<Wetter> | null;
created_by: number;
}
export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number }> {
return call('LB_CREATE_LOGBUCH', data);
}
export async function updateLogbuch(
id: number,
userId: number,
userRole: string,
data: Omit<CreateLogbuchData, 'created_by'>
): Promise<void> {
await call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data });
}
export async function deleteLogbuch(
id: number,
userId: number,
userRole: string
): Promise<void> {
await call('LB_DELETE_LOGBUCH', { id, user_id: userId, user_role: userRole });
}
// ---- BEOs & Objekte ----
export async function getBeos(): Promise<BeoOption[]> {
return call('LB_GET_BEOS');
}
export async function getObjekte(kategorie: 'stern' | 'sonne' = 'stern'): Promise<ObjektOption[]> {
return call('LB_GET_OBJEKTE', { kategorie });
}
export async function createObjekt(name: string, kategorie: string = 'stern'): Promise<ObjektOption> {
return call('LB_CREATE_OBJEKT', { name, kategorie });
}
export async function updateObjekt(id: number, name: string, kategorie?: string): Promise<ObjektOption> {
return call('LB_UPDATE_OBJEKT', { id, name, ...(kategorie ? { kategorie } : {}) });
}
export async function deleteObjekt(id: number): Promise<void> {
await call('LB_DELETE_OBJEKT', { id });
}
export async function listObjekteAdmin(): Promise<{ ID: number; Name: string; LastUsed: string | null; Kategorie: string }[]> {
return call('LB_LIST_OBJEKTE_ADMIN');
}
// ---- Auswertungen ----
export interface FahrkostenRow {
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
export async function getFahrkosten(ab: string): Promise<FahrkostenRow[]> {
return call('LB_FAHRKOSTEN', { ab });
}
export interface StatistikResult {
monthly: {
monat: number;
tageFuehrungen: number; tageBeob: number; tageTD: number;
tageSonst: number; tageBEOS: number; tagesToT: number; tageGesamt: number;
besucherRF: number; besucherSF: number; besucherSonF: number;
besucherPrF: number; besucherToT: number; besucherGesamt: number;
}[];
cumulative: number;
tage: number;
year: number;
}
export async function getStatistik(year: number): Promise<StatistikResult> {
return call('LB_STATISTIK', { year });
}
// ---- Backup ----
export interface BackupTable {
name: string;
createSql: string;
rows: Record<string, string | number | null>[];
}
export async function getBackupData(): Promise<{ tables: BackupTable[] }> {
return call('LB_BACKUP_DATA');
}
+6 -6
View File
@@ -4,11 +4,11 @@ import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session'; const SESSION_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 60 * 60 * 1000; const SESSION_DURATION = 60 * 60 * 1000;
const secretKey = process.env.AUTH_SECRET; function getKey(): Uint8Array {
if (!secretKey) { const secretKey = process.env.AUTH_SECRET;
throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!'); if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
return new TextEncoder().encode(secretKey);
} }
const key = new TextEncoder().encode(secretKey);
export interface SessionData { export interface SessionData {
kuerzel: string; kuerzel: string;
@@ -25,12 +25,12 @@ async function encrypt(payload: SessionData): Promise<string> {
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt)) .setExpirationTime(new Date(payload.expiresAt))
.sign(key); .sign(getKey());
} }
async function decrypt(token: string): Promise<SessionData | null> { async function decrypt(token: string): Promise<SessionData | null> {
try { try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] }); const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData; return payload as unknown as SessionData;
} catch { } catch {
return null; return null;
+1 -1
View File
@@ -7,7 +7,6 @@ const nextConfig: NextConfig = {
{ {
source: '/(.*)', source: '/(.*)',
headers: [ headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
@@ -19,6 +18,7 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
}; };
export default nextConfig; export default nextConfig;
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.6", "version": "1.10.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,8 +11,7 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
"mysql2": "^3.22.3", "next": "16.1.6",
"next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
+15 -1
View File
@@ -3,7 +3,8 @@ import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session'; const SESSION_COOKIE_NAME = 'logbuch_session';
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production'; const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
const key = new TextEncoder().encode(secretKey); const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
@@ -13,6 +14,11 @@ export async function proxy(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Allow the statistik grafik proxy route to be called without an app session cookie
if (pathname.startsWith('/api/statistik/grafik')) {
return NextResponse.next();
}
const cookie = request.cookies.get(SESSION_COOKIE_NAME); const cookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!cookie?.value) { if (!cookie?.value) {
@@ -31,7 +37,15 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/', request.url)); return NextResponse.redirect(new URL('/', request.url));
} }
if (pathname === '/api/statistik/grafik') {
return NextResponse.next(); return NextResponse.next();
}
return NextResponse.next({
headers: {
'X-Frame-Options': 'DENY',
},
});
} catch { } catch {
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL('/login', request.url));
} }
+48 -135
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Logbuch Sternwarte Welzheim</title> <title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -15,14 +15,12 @@
background: #f4f6fb; background: #f4f6fb;
} }
/* ── Layout ── */
.page { .page {
max-width: 860px; max-width: 860px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem 4rem; padding: 2rem 1.5rem 4rem;
} }
/* ── Header ── */
header { header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%); background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff; color: #fff;
@@ -33,24 +31,10 @@
align-items: center; align-items: center;
gap: 1.2rem; gap: 1.2rem;
} }
header .star { header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
font-size: 2.8rem; header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
flex-shrink: 0; header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
line-height: 1;
}
header h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
header p {
margin-top: 0.3rem;
font-size: 0.9rem;
opacity: 0.75;
}
/* ── TOC ── */
nav.toc { nav.toc {
background: #fff; background: #fff;
border: 1px solid #d8e0f0; border: 1px solid #d8e0f0;
@@ -59,36 +43,21 @@
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
nav.toc h2 { nav.toc h2 {
font-size: 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
font-weight: 700; letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7a9b;
margin-bottom: 0.75rem;
} }
nav.toc ol { nav.toc ol {
list-style: none; list-style: none; counter-reset: toc;
counter-reset: toc; display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem 2rem;
} }
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; } nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before { nav.toc li::before {
content: counter(toc) "."; content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-size: 0.8rem; font-weight: 700; min-width: 1.4rem;
color: #85b7d7;
font-weight: 700;
min-width: 1.4rem;
}
nav.toc a {
color: #2e4e8a;
text-decoration: none;
font-size: 0.92rem;
} }
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; } nav.toc a:hover { text-decoration: underline; }
/* ── Sections ── */
section { section {
background: #fff; background: #fff;
border: 1px solid #d8e0f0; border: 1px solid #d8e0f0;
@@ -98,127 +67,69 @@
} }
h2.section-title { h2.section-title {
font-size: 1.15rem; font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
font-weight: 700; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
color: #1a2d5a;
margin-bottom: 1.2rem;
padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7; border-bottom: 2px solid #85b7d7;
display: flex; display: flex; align-items: center; gap: 0.6rem;
align-items: center;
gap: 0.6rem;
} }
h2.section-title .num { h2.section-title .num {
background: #85b7d7; background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
color: #1a2d5a; width: 1.6rem; height: 1.6rem; border-radius: 50%;
font-size: 0.78rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
font-weight: 800;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
h3 { h3 {
font-size: 0.95rem; font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
font-weight: 700; margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
color: #2e4e8a;
margin: 1.3rem 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
h3:first-child { margin-top: 0; } h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; } p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; } p:last-child { margin-bottom: 0; }
/* ── Lists ── */ ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
ul, ol {
padding-left: 1.4rem;
margin-bottom: 0.7rem;
}
li { margin-bottom: 0.35rem; } li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; } li:last-child { margin-bottom: 0; }
/* ── Tables ── */ table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin: 0.8rem 0;
}
th { th {
background: #eef2fa; background: #eef2fa; color: #2e4e8a; font-weight: 700;
color: #2e4e8a; text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
font-weight: 700;
text-align: left;
padding: 0.5rem 0.9rem;
border: 1px solid #d0d8ee;
}
td {
padding: 0.45rem 0.9rem;
border: 1px solid #e0e6f5;
vertical-align: top;
} }
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; } tr:nth-child(even) td { background: #f7f9fd; }
/* ── Code / kbd ── */
code { code {
background: #eef2fa; background: #eef2fa; color: #1a2d5a;
color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace; font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
padding: 0.1em 0.35em;
border-radius: 4px;
} }
/* ── Callout boxes ── */
.callout { .callout {
background: #eef6fb; background: #eef6fb; border-left: 4px solid #85b7d7;
border-left: 4px solid #85b7d7; border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
border-radius: 0 8px 8px 0; margin: 0.9rem 0; font-size: 0.9rem;
padding: 0.8rem 1rem;
margin: 0.9rem 0;
font-size: 0.9rem;
}
.callout.warn {
background: #fff8ec;
border-left-color: #f5a623;
}
.callout.danger {
background: #fff0f0;
border-left-color: #e05252;
} }
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; } strong { font-weight: 700; }
/* ── Responsive ── */ .back-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
text-decoration: none; cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media (max-width: 600px) { @media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; } nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; } header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; } section { padding: 1.2rem 1rem; }
} }
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 1.5rem;
padding: 0.5rem 1rem;
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 8px;
color: #2e4e8a;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media print { @media print {
body { background: #fff; font-size: 12pt; } body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; } .page { max-width: none; padding: 0; }
@@ -233,13 +144,13 @@
<body> <body>
<div class="page"> <div class="page">
<a href="/" class="back-btn">← Zurück zum Logbuch</a> <a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header> <header>
<div class="star"></div> <div class="star"></div>
<div> <div>
<h1>Bedienungsanleitung</h1> <h1>Bedienungsanleitung</h1>
<p>Logbuch Sternwarte Welzheim</p> <p>Führungsbuch Sternwarte Welzheim</p>
</div> </div>
</header> </header>
@@ -265,7 +176,7 @@
<li><strong>Passwort</strong> individuell gesetztes Passwort</li> <li><strong>Passwort</strong> individuell gesetztes Passwort</li>
</ul> </ul>
<div class="callout warn"> <div class="callout warn">
Wurde das Passwort noch nicht geändert (Anzeige „Standard"), muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>. Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>.
</div> </div>
</section> </section>
@@ -303,7 +214,6 @@
<h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2> <h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2>
<h3>Pflichtfelder</h3> <h3>Pflichtfelder</h3>
<p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p> <p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p>
<table> <table>
<thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead> <thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead>
@@ -362,7 +272,7 @@
<p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p> <p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p>
<ul> <ul>
<li><strong>Monatsnavigation</strong> Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; <strong>Aktueller Monat</strong> springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).</li> <li><strong>Monatsnavigation</strong> Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; <strong>Aktueller Monat</strong> springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).</li>
<li><strong>Suchfeld</strong> Freitextsuche über alle Einträge des Logbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.</li> <li><strong>Suchfeld</strong> Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.</li>
<li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li> <li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li>
</ul> </ul>
@@ -395,6 +305,9 @@
</ul> </ul>
</li> </li>
</ul> </ul>
<div class="callout">
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
</div>
</section> </section>
<!-- ── 6. Drucken ── --> <!-- ── 6. Drucken ── -->
@@ -428,7 +341,7 @@
<li><strong>Objekt umbenennen</strong> Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li> <li><strong>Objekt umbenennen</strong> Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li>
<li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li> <li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
</ul> </ul>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Logbucheinträgen.</div> <div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
</section> </section>
</div> </div>