feat: storage management (retention, source purge) + Synology NAS backup
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
@@ -15,6 +15,8 @@ export function ensureInit(): Promise<void> {
|
||||
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; });
|
||||
}
|
||||
|
||||
@@ -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<StorageStats> {
|
||||
const counts = await one<any>(`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<any>(
|
||||
`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<any>(
|
||||
`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
|
||||
}
|
||||
@@ -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<NasCfg | null> {
|
||||
const s = await one<any>(`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<void> {
|
||||
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<any>('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.' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user