Compare commits
16 Commits
8c60089325
...
7571b14422
| Author | SHA1 | Date | |
|---|---|---|---|
| 7571b14422 | |||
| d88005d9fe | |||
| 00a3f02d80 | |||
| 43ddbbcf72 | |||
| 49563e6bd0 | |||
| 4316670ce4 | |||
| a12c62bbdc | |||
| 072ca040bb | |||
| 52234132ca | |||
| 93b449412f | |||
| c3bac456e7 | |||
| fb0b64c36c | |||
| 2e875ed1ad | |||
| d99a696ef0 | |||
| 10b52d268e | |||
| cf95f3027f |
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
*.md
|
||||||
@@ -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,6 +25,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -21,6 +21,7 @@ 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 grafikSrc = '/api/statistik/grafik';
|
||||||
|
|
||||||
@@ -47,6 +48,17 @@ 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">
|
||||||
@@ -59,12 +71,25 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
</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') && (
|
||||||
<a
|
<>
|
||||||
href="/admin"
|
<button
|
||||||
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"
|
onClick={handleBackup}
|
||||||
>
|
disabled={backupState === 'running'}
|
||||||
Admin
|
className={`text-xs sm:text-sm px-2 sm:px-3 py-1.5 rounded-lg text-gray-900 disabled:opacity-50 ${
|
||||||
</a>
|
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
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query, getPool } from '@/lib/db';
|
import { query, getPool } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
import { triggerBackup } from '@/lib/backup';
|
||||||
import type { SelectedObjekt } from '@/types/logbuch';
|
import type { SelectedObjekt } from '@/types/logbuch';
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PUT /api/logbuch/[id]:', error);
|
console.error('PUT /api/logbuch/[id]:', error);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query, getPool } from '@/lib/db';
|
import { query, getPool } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
import { triggerBackup } from '@/lib/backup';
|
||||||
import type { SelectedObjekt } from '@/types/logbuch';
|
import type { SelectedObjekt } from '@/types/logbuch';
|
||||||
|
|
||||||
const LIST_SQL =
|
const LIST_SQL =
|
||||||
@@ -137,6 +138,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
triggerBackup();
|
||||||
return NextResponse.json({ id: logbuchId }, { status: 201 });
|
return NextResponse.json({ id: logbuchId }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('POST /api/logbuch:', error);
|
console.error('POST /api/logbuch:', error);
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ services:
|
|||||||
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_HOST: logbuch_mysql
|
||||||
DB_PORT: 3306
|
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:
|
depends_on:
|
||||||
|
|||||||
+136
@@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user