diff --git a/src/lib/init.ts b/src/lib/init.ts index 7aae4a3..1b40b08 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -15,6 +15,8 @@ export function ensureInit(): Promise { try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); } try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); } catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); } + try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); } + catch (e) { console.error('[init] Wartung-Start fehlgeschlagen:', e); } console.log('[init] Klarbild bereit.'); })().catch((e) => { started = null; throw e; }); } diff --git a/src/lib/maintenance.ts b/src/lib/maintenance.ts new file mode 100644 index 0000000..780690a --- /dev/null +++ b/src/lib/maintenance.ts @@ -0,0 +1,87 @@ +// Speicherverwaltung: Nutzung anzeigen, Aufräumen (Retention), Quellen löschen. +// Ziel: „den Server nicht vollmüllen". +import { stat, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { one, query } from './db'; +import { deleteObject } from './storage'; + +const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase(); +const DIR = process.env.STORAGE_DIR || '/data'; + +async function dirBytes(path: string): Promise<{ bytes: number; files: number }> { + let bytes = 0, files = 0; + let entries: any[] = []; + try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; } + for (const e of entries) { + const p = join(path, e.name); + if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; } + else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } } + } + return { bytes, files }; +} + +export interface StorageStats { + driver: string; bytes: number | null; files: number | null; + sources: number; results: number; thumbs: number; items: number; +} + +/** Speicher-Kennzahlen. Byte-Genauigkeit nur beim fs-Treiber. */ +export async function storageStats(): Promise { + const counts = await one(`SELECT + count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources, + count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results, + count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs, + count(*)::int AS items FROM items`); + let bytes: number | null = null, files: number | null = null; + if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; } + return { driver: DRIVER, bytes, files, + sources: counts?.sources || 0, results: counts?.results || 0, + thumbs: counts?.thumbs || 0, items: counts?.items || 0 }; +} + +/** Löscht Positionen (samt Objekten), die älter als N Tage sind. */ +export async function runRetention(days: number): Promise<{ deleted: number }> { + if (!days || days <= 0) return { deleted: 0 }; + const rows = await query( + `SELECT id, source_path, source_paths, result_path, thumb_path FROM items + WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]); + for (const it of rows) { + const keys = [it.source_path, it.result_path, it.thumb_path, + ...((it.source_paths as string[]) || [])].filter(Boolean); + for (const k of keys) await deleteObject(k).catch(() => {}); + await query('DELETE FROM items WHERE id=$1', [it.id]); + } + return { deleted: rows.length }; +} + +/** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */ +export async function purgeSources(): Promise<{ purged: number }> { + const rows = await query( + `SELECT id, source_path, source_paths FROM items WHERE status='done' + AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`); + let purged = 0; + for (const it of rows) { + const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean); + for (const k of keys) await deleteObject(k).catch(() => {}); + await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]); + purged += keys.length; + } + return { purged }; +} + +let timer: NodeJS.Timeout | null = null; +/** Täglicher Retention-Lauf (falls in den Einstellungen aktiviert). */ +export function startMaintenance(): void { + if (timer) return; + const tick = async () => { + try { + const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1'); + if (s?.retention_days && s.retention_days > 0) { + const r = await runRetention(s.retention_days); + if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`); + } + } catch (e) { console.error('[maintenance]', e); } + }; + timer = setInterval(tick, 6 * 60 * 60 * 1000); // alle 6 h + setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min +} diff --git a/src/lib/nas.ts b/src/lib/nas.ts new file mode 100644 index 0000000..4879f65 --- /dev/null +++ b/src/lib/nas.ts @@ -0,0 +1,88 @@ +// Zweite Datensicherung auf ein Synology-NAS (SFTP oder FTPS, wie Picdrop). +// Ergebnisse werden zusätzlich zur Bibliothek/Picdrop auf das NAS gespiegelt. +import posixpath from 'node:path/posix'; +import { one, query } from './db'; +import { decrypt } from './crypto'; +import { getObject } from './storage'; + +export interface NasCfg { + host: string; protocol: 'ftps' | 'sftp'; port: number; + user: string; password: string; basePath: string; +} + +export async function loadNasConfig(): Promise { + const s = await one(`SELECT nas_enabled, nas_host, nas_protocol, nas_port, nas_user, + nas_password_enc, nas_base_path FROM settings WHERE id=1`); + if (!s?.nas_enabled || !s?.nas_host || !s?.nas_user || !s?.nas_password_enc) return null; + let password = ''; + try { password = decrypt(s.nas_password_enc); } catch { return null; } + return { + host: s.nas_host, protocol: (s.nas_protocol || 'sftp'), + port: s.nas_port || (s.nas_protocol === 'ftps' ? 21 : 22), + user: s.nas_user, password, basePath: s.nas_base_path || '/', + }; +} + +export async function testNas(cfg: NasCfg): Promise<{ ok: boolean; message: string }> { + try { + if (cfg.protocol === 'sftp') { + const SftpClient = (await import('ssh2-sftp-client')).default; + const c = new SftpClient(); + await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 }); + await c.list(cfg.basePath || '/'); + await c.end(); + } else { + const { Client } = await import('basic-ftp'); + const c = new Client(15000); + await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); + await c.list(cfg.basePath || '/'); + c.close(); + } + return { ok: true, message: 'NAS-Verbindung erfolgreich.' }; + } catch (e: any) { + return { ok: false, message: e?.message || 'NAS-Verbindung fehlgeschlagen.' }; + } +} + +async function uploadToNas(cfg: NasCfg, remoteDir: string, filename: string, buf: Buffer): Promise { + const dir = posixpath.join(cfg.basePath || '/', remoteDir); + const finalPath = posixpath.join(dir, filename); + const { Readable } = await import('node:stream'); + if (cfg.protocol === 'sftp') { + const SftpClient = (await import('ssh2-sftp-client')).default; + const c = new SftpClient(); + await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 }); + try { + if (!(await c.exists(dir))) await c.mkdir(dir, true); + await c.put(buf, finalPath); + } finally { await c.end(); } + } else { + const { Client } = await import('basic-ftp'); + const c = new Client(20000); + await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); + try { + await c.ensureDir(dir); + await c.uploadFrom(Readable.from(buf), finalPath); + } finally { c.close(); } + } +} + +/** Spiegelt ein fertiges Item auf das NAS (Ordnerstruktur klarbild/JJJJ-MM/). */ +export async function mirrorItemToNas(itemId: string): Promise<{ ok: boolean; message: string }> { + const cfg = await loadNasConfig(); + if (!cfg) return { ok: false, message: 'NAS nicht konfiguriert.' }; + const it = await one('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]); + if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' }; + await query(`UPDATE items SET nas_status='pending' WHERE id=$1`, [itemId]); + try { + const buf = await getObject(it.result_path); + const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // JJJJ-MM + await uploadToNas(cfg, posixpath.join('klarbild', ym), it.filename || `${it.id}.png`, buf); + await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]); + return { ok: true, message: 'Auf NAS gesichert.' }; + } catch (e: any) { + console.error('[nas] mirror', itemId, 'fehlgeschlagen:', e?.message || e); + await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]); + return { ok: false, message: e?.message || 'NAS-Sicherung fehlgeschlagen.' }; + } +} diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index a796354..40267e7 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -19,6 +19,11 @@ export const GET: APIRoute = async () => { picdrop_password_set: !!s.picdrop_password_enc, default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, + // Speicherverwaltung + keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days, + // NAS-Sicherung + nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, + nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc, } }); }; @@ -29,9 +34,17 @@ export const PATCH: APIRoute = async ({ request }) => { if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); + if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password))); + const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled']; for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', - 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) { - if (col in b) set(col, b[col] === '' ? null : b[col]); + 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url', + 'keep_sources', 'make_thumbnails', 'retention_days', + 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { + if (col in b) { + const raw = b[col]; + const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw); + set(col, val); + } } if (!sets.length) return json({ ok: true }); await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args); diff --git a/src/pages/api/admin/storage.ts b/src/pages/api/admin/storage.ts new file mode 100644 index 0000000..ab2ca9d --- /dev/null +++ b/src/pages/api/admin/storage.ts @@ -0,0 +1,21 @@ +import type { APIRoute } from 'astro'; +import { storageStats, runRetention, purgeSources } from '../../../lib/maintenance'; +import { one } from '../../../lib/db'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const GET: APIRoute = async () => json({ stats: await storageStats() }); + +// POST { action: 'retention' | 'purge_sources' } +export const POST: APIRoute = async ({ request }) => { + const b = await request.json().catch(() => ({})); + if (b.action === 'purge_sources') return json(await purgeSources()); + if (b.action === 'retention') { + const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1'); + if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400); + return json(await runRetention(s.retention_days)); + } + return json({ error: 'Unbekannte Aktion.' }, 400); +}; diff --git a/src/pages/api/admin/test-nas.ts b/src/pages/api/admin/test-nas.ts new file mode 100644 index 0000000..833f47a --- /dev/null +++ b/src/pages/api/admin/test-nas.ts @@ -0,0 +1,12 @@ +import type { APIRoute } from 'astro'; +import { loadNasConfig, testNas } from '../../../lib/nas'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = async () => { + const cfg = await loadNasConfig(); + if (!cfg) return json({ ok: false, message: 'NAS nicht vollständig konfiguriert (oder deaktiviert).' }); + return json(await testNas(cfg)); +};