diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e333d82 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..5d67e36 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -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 diff --git a/ANLEITUNG.md b/ANLEITUNG.md index 082edd1..acee7ec 100644 --- a/ANLEITUNG.md +++ b/ANLEITUNG.md @@ -175,3 +175,28 @@ Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung. - **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 löschen**: × in der Zeile – es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Logbucheinträgen. + +## Neue Features (Statistik Grafik Proxy) + +Dieses Release ergänzt eine serverseitige Proxy-Lösung für das interne Statistik-Portal, damit geschützte Diagramme sicher eingeblendet werden können, ohne Zugangsdaten im Browser zu speichern. + +- Was neu ist: + - Server-seitiger Proxy unter `/api/statistik/grafik` und Catch-all `/api/statistik/grafik/*`. + - Holt die Statistik-Seite mit Basic-Auth (serverseitig) und liefert sie an den Browser weiter. + - Schreibt die HTML-Antwort so um, dass relative Assets (CSS/JS/Images) über die Proxy-URL geladen werden (es wird ein `` eingefügt). + - Leitet auch AJAX-POSTs (z. B. `php/statistic.php`) weiter – Methoden und Bodies werden beibehalten. + - Entfernt framing-blockierende Header (z. B. `X-Frame-Options`, CSP-Meta-Tags) in der proxied HTML-Antwort. + +- Wichtige Environment-Variablen (nur serverseitig): + - `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 die Statistik jetzt in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, weil manche Browser (insbesondere Firefox und Safari) Probleme mit eingebetteten, geschützten Seiten machen. + +- Sicherheit & Hinweise: + - Setze die `STATISTIK_...` Variablen in deiner Server-Umgebung (Docker secrets, CI/CD env vars, oder im Host-Umfeld). Niemals Zugangsdaten ins Repository committen. + - Die Proxy-Route ist so konfiguriert, dass Assets und AJAX-Aufrufe über den gleichen Proxy laufen, damit die Seite vollständig funktioniert. + +Wenn du möchtest, pushe ich die Änderungen an `ANLEITUNG.md` in `origin/main` für dich. diff --git a/README.md b/README.md index e215bc4..d743cf8 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,24 @@ 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. 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 (``). + +- 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). + +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. diff --git a/app/MainClient.tsx b/app/MainClient.tsx index 9d85fb9..e8782b3 100644 --- a/app/MainClient.tsx +++ b/app/MainClient.tsx @@ -22,6 +22,8 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) { const [refreshKey, setRefreshKey] = useState(0); const [editEntry, setEditEntry] = useState(null); + const grafikSrc = '/api/statistik/grafik'; + const version = packageJson.version; const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || @@ -98,7 +100,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) { .map((tab) => ( +
+ + +
diff --git a/app/api/statistik/grafik/[...slug]/route.ts b/app/api/statistik/grafik/[...slug]/route.ts new file mode 100644 index 0000000..bd9487e --- /dev/null +++ b/app/api/statistik/grafik/[...slug]/route.ts @@ -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)); +} diff --git a/app/api/statistik/grafik/proxy.ts b/app/api/statistik/grafik/proxy.ts new file mode 100644 index 0000000..a93b44a --- /dev/null +++ b/app/api/statistik/grafik/proxy.ts @@ -0,0 +1,110 @@ +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 = {}; + 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(/]*?(?: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(/]*)>/i, ``) + .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 = {}; + 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 = {}; + upstream.headers.forEach((value, key) => { + if (!blockedHeaders.has(key.toLowerCase())) { + outHeaders[key] = value; + } + }); + + const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || ''; + if (contentType.toLowerCase().includes('text/html')) { + 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 }); + } +} diff --git a/app/api/statistik/grafik/route.ts b/app/api/statistik/grafik/route.ts new file mode 100644 index 0000000..f44cb95 --- /dev/null +++ b/app/api/statistik/grafik/route.ts @@ -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)); +} diff --git a/components/DateInput.tsx b/components/DateInput.tsx new file mode 100644 index 0000000..4ecc1de --- /dev/null +++ b/components/DateInput.tsx @@ -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 { + 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 = 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(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 ( +
+
+ 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' + }`} + /> + +
+ + {open && ( +
+
+ +
{monthLabel(viewMonth)}
+ +
+ +
+ {WEEKDAY_SHORT.map((wd) => ( +
+ {wd} +
+ ))} +
+ +
+ {dayCells.map((day, idx) => ( + + ))} +
+
+ )} + + {error && ( +

+ Ungueltiges Datum (YYYY-MM-DD) +

+ )} +
+ ); +} diff --git a/next.config.ts b/next.config.ts index e4f3940..876981b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,7 +7,6 @@ const nextConfig: NextConfig = { { source: '/(.*)', headers: [ - { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, @@ -19,6 +18,7 @@ const nextConfig: NextConfig = { }, ]; }, + }; export default nextConfig; diff --git a/package.json b/package.json index 2954801..47b20b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logbuch", - "version": "1.7.7", + "version": "1.7.8", "private": true, "scripts": { "dev": "next dev", diff --git a/proxy.ts b/proxy.ts index b593726..fcc7722 100644 --- a/proxy.ts +++ b/proxy.ts @@ -13,6 +13,11 @@ export async function proxy(request: NextRequest) { 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); if (!cookie?.value) { @@ -31,7 +36,15 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(new URL('/', request.url)); } - return NextResponse.next(); + if (pathname === '/api/statistik/grafik') { + return NextResponse.next(); + } + + return NextResponse.next({ + headers: { + 'X-Frame-Options': 'DENY', + }, + }); } catch { return NextResponse.redirect(new URL('/login', request.url)); }