Compare commits

...

16 Commits

Author SHA1 Message Date
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
10 changed files with 205 additions and 17 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
.git
.gitignore
node_modules
.next
*.md
+4
View File
@@ -12,7 +12,9 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DATE
ARG NEXT_PUBLIC_FAHRKOSTEN_SATZ=15
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_PUBLIC_FAHRKOSTEN_SATZ=${NEXT_PUBLIC_FAHRKOSTEN_SATZ}
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
@@ -23,6 +25,8 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache mysql-client openssh-client
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
+25
View File
@@ -21,6 +21,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
const grafikSrc = '/api/statistik/grafik';
@@ -47,6 +48,17 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
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 (
<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">
@@ -59,12 +71,25 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
</h1>
<div className="flex items-center gap-2 shrink-0">
{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
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"
>
Admin
</a>
</>
)}
<button
onClick={handleLogout}
+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
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
import type { SelectedObjekt } from '@/types/logbuch';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -68,6 +69,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
);
}
triggerBackup();
return NextResponse.json({ ok: true });
} catch (error) {
console.error('PUT /api/logbuch/[id]:', error);
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
import type { SelectedObjekt } from '@/types/logbuch';
const LIST_SQL =
@@ -137,6 +138,7 @@ export async function POST(request: NextRequest) {
);
}
triggerBackup();
return NextResponse.json({ id: logbuchId }, { status: 201 });
} catch (error) {
console.error('POST /api/logbuch:', error);
+4 -4
View File
@@ -40,14 +40,14 @@ services:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
env_file: .env
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
BACKUP_SSH_KEY_PATH: /run/secrets/backup_ssh_key
volumes:
- ${BACKUP_SSH_KEY_FILE:-/dev/null}:/run/secrets/backup_ssh_key:ro
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on:
+136
View File
@@ -0,0 +1,136 @@
import { createWriteStream, mkdirSync, unlinkSync } from 'fs';
import { createGzip } from 'zlib';
import { join } from 'path';
import { spawn } from 'child_process';
import { getPool } from './db';
export function triggerBackup(): void {
setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e)));
}
async function dumpToFile(filePath: string): Promise<void> {
const dbName = process.env.DB_NAME || 'sternwarte';
const pool = getPool();
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-- Datenbank: ${dbName} (ohne Tabelle beos)\n\nSET FOREIGN_KEY_CHECKS=0;\n\n`);
const [tableRows] = await pool.query('SHOW TABLES') as [Record<string, string>[], unknown];
const tables = tableRows
.map((r) => Object.values(r)[0])
.filter((t) => t !== 'beos');
for (const table of tables) {
const [[createRow]] = await pool.query(`SHOW CREATE TABLE \`${table}\``) as [Record<string, string>[], unknown];
const createSql = Object.values(createRow)[1];
await write(`DROP TABLE IF EXISTS \`${table}\`;\n`);
await write(`${createSql};\n\n`);
const [rows] = await pool.query(`SELECT * FROM \`${table}\``) as [Record<string, unknown>[], unknown];
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);
if (v instanceof Date) return `'${v.toISOString().slice(0, 19).replace('T', ' ')}'`;
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}).join(', ') + ')'
).join(',\n ');
await write(`INSERT INTO \`${table}\` (${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',
];
// Zielverzeichnis auf Remote anlegen falls nicht vorhanden
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}`);
// Backups älter als 30 Tage auf Remote löschen
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 */ }
}
}
+5 -5
View File
@@ -4,11 +4,11 @@ import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 60 * 60 * 1000;
function getKey(): Uint8Array {
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) {
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 {
kuerzel: string;
@@ -25,12 +25,12 @@ async function encrypt(payload: SessionData): Promise<string> {
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
.sign(getKey());
}
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData;
} catch {
return null;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "logbuch",
"version": "1.8.1",
"version": "1.9.0",
"private": true,
"scripts": {
"dev": "next dev",