feat: mirror-all-to-NAS, content reset (danger zone), in-app changelog + CHANGELOG.md, How-To refresh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 20:52:48 +00:00
parent 79454c0b92
commit 3a098d708c
7 changed files with 191 additions and 5 deletions
+39
View File
@@ -0,0 +1,39 @@
# Changelog
All notable changes to Klarbild are documented here. Newest first.
## 2026-07-23 — Compose, generate & second backup
### Added
- **Compose mode** — combine multiple images + a text description into one new image.
- **Generate mode** — create images from text only, no source needed.
- **The Frame "extend"** — outpaint more scene around the motif instead of only cropping.
- **Reuse** — take a finished result back into the studio as a new source to edit further.
- **Alternatives** — generate additional versions of the same image, grouped with a switcher.
- **Folders** — filter, rename and delete in the library; per-folder delivery gallery mapping.
- **Bulk actions** — download selected results as a ZIP, move several into a folder at once.
- **Thumbnails** — low-res previews for the library grid.
- **Models** — quality tiers incl. open-source (FLUX.2); the used model is shown per image.
- **Second backup** — mirror every result over SFTP/FTPS to a NAS (works over Tailscale too),
with a "NAS ✓" badge and a "mirror all existing images" action.
- **Storage management** — thumbnails, delete sources after processing, auto-retention.
- **Telegram** — fully button-driven (persistent keyboard + command menu): edit, compose,
"new image", reuse — no slash commands required.
- **Reset** — wipe library, jobs and folders cleanly (keeps users, settings, models, recipes),
guarded by a typed confirmation.
- **Help page**, in-app **changelog**, and a NAS **reachability diagnostic**.
### Fixed / hardened
- Real per-image cost cap (monthly budget) enforced by the worker.
- Idempotent job completion (no duplicate delivery/notification).
- Before/after comparison works for composed images; hidden for text-only results.
## 2026-07-22 — Foundation
### Added
- Astro 5 + React islands UI (mobile-first), PostgreSQL, fs/S3 storage abstraction.
- pg-boss job queue with in-process worker; sharp pipeline (exact cm/dpi print sizes,
sticker contour).
- OpenRouter image gateway; admin-managed models.
- Picdrop delivery over SFTP/FTPS; recipes; library with before/after.
- Telegram bot (grammY webhook); argon2 auth; AES-256-GCM secret encryption.
+32 -2
View File
@@ -80,6 +80,22 @@ export default function AdminApp() {
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK'));
load();
};
const mirrorAll = async () => {
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
notify('Sichere aufs NAS … (kann dauern)');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
notify(r.total != null ? `${r.mirrored}/${r.total} aufs NAS gesichert${r.failed ? `, ${r.failed} fehlgeschlagen` : ''}.` : (r.error || 'Fehler'));
load();
};
const resetAll = async () => {
const t = prompt('ACHTUNG: Löscht ALLE Bilder, Aufträge und Ordner unwiderruflich (Nutzer, Einstellungen, Modelle & Rezepte bleiben).\n\nZum Bestätigen RESET eingeben:');
if (t !== 'RESET') { if (t != null) notify('Abgebrochen — nicht bestätigt.'); return; }
notify('Setze zurück …');
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
notify(r.ok ? `Zurückgesetzt: ${r.deleted_items} Bilder entfernt.` : (r.error || 'Fehler'));
load();
};
const addModel = async (m: any) => {
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
@@ -200,8 +216,9 @@ export default function AdminApp() {
</div>
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/homes/klarbild oder /volume1/…" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
<div className="fein">Bei Synology am einfachsten SFTP aktivieren (Systemsteuerung Terminal &amp; SNMP SFTP). Zugangsdaten werden verschlüsselt gespeichert.</div>
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
</div>
</section>
@@ -257,6 +274,15 @@ export default function AdminApp() {
</div>
</section>
<section className="karte gefahr">
<div className="kopfzeile"><span className="mono-label">Zurücksetzen</span></div>
<div className="steuer">
<div className="fein">Löscht <b>alle Bilder, Aufträge und Ordner</b> unwiderruflich, um sauber neu zu starten.
Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".</div>
<button className="knopf warn" onClick={resetAll}>Bibliothek &amp; Aufträge zurücksetzen</button>
</div>
</section>
{toast && <div className="toast">{toast}</div>}
<AdminStyles />
</div>
@@ -275,6 +301,10 @@ function AdminStyles() {
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.knopf.warn{background:var(--err);}
.karte.gefahr{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
.karte.gefahr .mono-label{color:var(--err);}
.steuer code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
.zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
+15
View File
@@ -69,6 +69,21 @@ export async function purgeSources(): Promise<{ purged: number }> {
return { purged };
}
/** Spiegelt alle fertigen Bilder aufs NAS (einmalige Nachsicherung). */
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
const { mirrorItemToNas, loadNasConfig } = await import('./nas');
if (!(await loadNasConfig())) return { mirrored: 0, failed: 0, total: 0 };
const rows = await query<{ id: string }>(
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL
AND (nas_status IS NULL OR nas_status <> 'mirrored') ORDER BY created_at`);
let mirrored = 0, failed = 0;
for (const it of rows) {
const r = await mirrorItemToNas(it.id);
r.ok ? mirrored++ : failed++;
}
return { mirrored, failed, total: rows.length };
}
let timer: NodeJS.Timeout | null = null;
/** Täglicher Retention-Lauf (falls in den Einstellungen aktiviert). */
export function startMaintenance(): void {
+19 -1
View File
@@ -5,8 +5,11 @@ import Base from '../layouts/Base.astro';
<div class="howto">
<div class="hkopf">
<h1>So funktioniert Klarbild</h1>
<div class="hkopf-akt">
<a class="druck" href="/changelog">Changelog</a>
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
</div>
</div>
<p class="lead">Aus Screenshots und Fundstücken werden druckfertige Bilder — bereinigt, freigestellt,
im richtigen Format, optional direkt bei Picdrop. Alles geht über die Website und über Telegram.</p>
@@ -48,6 +51,10 @@ import Base from '../layouts/Base.astro';
<li><b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut
bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.</li>
<li><b>Vorher/Nachher</b>: zeigt den Vergleich Vorlage ↔ Ergebnis (bei reinen Text-Bildern entfällt das).</li>
<li><b>Ordner verwalten</b>: Ordner-Chip antippen, dann ✎ zum Umbenennen (inkl. Galerie) oder ✕ zum Löschen
(die Bilder bleiben erhalten).</li>
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
verschieben.</li>
</ul>
</section>
@@ -67,6 +74,16 @@ import Base from '../layouts/Base.astro';
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
</section>
<section>
<h2>6 · Sicherung & sauber neu starten (Admin)</h2>
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
<p>Zum Aufräumen gibt es <b>„Bibliothek &amp; Aufträge zurücksetzen"</b>: löscht alle Bilder, Aufträge und Ordner
(Nutzer, Einstellungen, Modelle und Rezepte bleiben) — mit Sicherheitsabfrage. Am besten vorher einmal
aufs NAS sichern.</p>
</section>
<section>
<h2>Tipps</h2>
<ul>
@@ -81,7 +98,8 @@ import Base from '../layouts/Base.astro';
.howto{max-width:760px;margin:0 auto;}
.hkopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);}
.hkopf-akt{display:flex;gap:8px;align-items:center;}
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.howto h2{font-size:1.05rem;margin:0 0 10px;}
+36
View File
@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Setzt Inhalte zurück: löscht alle Bilder/Aufträge/Ordner + zugehörige Dateien.
* Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten.
* Erfordert { confirm: 'RESET' }. */
export const POST: APIRoute = async ({ request, locals }) => {
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
const b = await request.json().catch(() => ({} as any));
if (b.confirm !== 'RESET') return json({ error: 'Bestätigung fehlt (confirm=RESET).' }, 400);
// Zuerst alle Objekte aus dem Speicher entfernen.
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
let objects = 0;
for (const it of items) {
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(() => {}); objects++; }
}
// Inhalts-Tabellen leeren (Konfiguration bleibt).
await query('DELETE FROM print_job_items');
await query('DELETE FROM print_jobs');
await query('DELETE FROM share_links');
await query('DELETE FROM telegram_drafts');
await query('DELETE FROM items');
await query('DELETE FROM jobs');
await query('DELETE FROM folders');
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
};
+2 -1
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro';
import { storageStats, runRetention, purgeSources } from '../../../lib/maintenance';
import { storageStats, runRetention, purgeSources, mirrorAllToNas } from '../../../lib/maintenance';
import { one } from '../../../lib/db';
export const prerender = false;
@@ -12,6 +12,7 @@ export const GET: APIRoute = async () => json({ stats: await storageStats() });
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
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);
+47
View File
@@ -0,0 +1,47 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Changelog · Klarbild">
<div class="clog">
<div class="ckopf">
<h1>Changelog</h1>
<a class="zurueck" href="/anleitung">← Zur Hilfe</a>
</div>
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
<section>
<div class="ver"><span class="tag">23.07.2026</span><h2>Kombinieren, Erzeugen & zweite Sicherung</h2></div>
<ul>
<li><b>Kombinieren:</b> mehrere Bilder + Beschreibung zu einem neuen Bild (z. B. „…​mit unserer Hündin Frieda").</li>
<li><b>Neu erzeugen:</b> Bilder allein aus Text, ganz ohne Vorlage.</li>
<li><b>The Frame „Erweitern":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.</li>
<li><b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.</li>
<li><b>Alternativen:</b> weitere Fassungen desselben Bildes erzeugen — gruppiert mit Umschalter.</li>
<li><b>Ordner:</b> in der Bibliothek filtern, umbenennen, löschen; je Ordner eigene Picdrop-Galerie.</li>
<li><b>Sammel-Aktionen:</b> mehrere Bilder als ZIP laden und auf einmal in einen Ordner verschieben.</li>
<li><b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).</li>
<li><b>Modelle:</b> Qualitätsstufen inkl. Open Source (FLUX.2); genutztes Modell wird angezeigt.</li>
<li><b>Zweite Sicherung (NAS):</b> jedes Ergebnis zusätzlich per SFTP/FTPS aufs NAS (auch über Tailscale) — mit „NAS ✓"-Anzeige und „Alle Bilder aufs NAS sichern".</li>
<li><b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.</li>
<li><b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild", Weiterbearbeiten, ganz ohne Befehle.</li>
<li><b>Zurücksetzen:</b> Bibliothek & Aufträge sauber leeren (mit Sicherheitsabfrage).</li>
<li><b>Hilfe-Seite</b> und dieser Changelog.</li>
<li>Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose fürs NAS.</li>
</ul>
</section>
</div>
<style>
.clog{max-width:760px;margin:0 auto;}
.ckopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.zurueck{color:var(--accent);text-decoration:none;font-size:.9rem;}
.lead{color:var(--soft);margin:4px 0 20px;}
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.ver h2{font-size:1.05rem;margin:0;}
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
.clog li{margin-bottom:6px;}
</style>
</Base>