feat: per-user visibility (own/shared), anonymous generations, private sessions (generate+forget), NSFW model gating
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
-- Zugriffsrechte, private Sessions, NSFW-Schalter.
|
||||||
|
|
||||||
|
-- Sichtbarkeit der Bibliothek + Datenschutz.
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS library_visibility text NOT NULL DEFAULT 'own'
|
||||||
|
CHECK (library_visibility IN ('own','shared')); -- own = jede/r sieht nur eigene; shared = alle sehen alles
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS anonymous_generations bool NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS private_allowed bool NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS allow_nsfw bool NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Pro Nutzer: private Sessions erzwingen.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS private_forced bool NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Private Aufträge: nur erzeugen, danach vergessen (nicht in Bibliothek, kein Delivery/Backup,
|
||||||
|
-- kein gespeicherter Prompt, Quellen sofort gelöscht, Ergebnis nach kurzer Zeit entfernt).
|
||||||
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS private bool NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Modelle als NSFW-fähig markieren (nur bei aktivem allow_nsfw wähl-/sichtbar).
|
||||||
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS nsfw bool NOT NULL DEFAULT false;
|
||||||
@@ -75,6 +75,8 @@ export default function AdminApp() {
|
|||||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||||
metadata_sidecar: !!s.metadata_sidecar,
|
metadata_sidecar: !!s.metadata_sidecar,
|
||||||
|
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||||
|
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||||
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
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_user: s.nas_user, nas_base_path: s.nas_base_path,
|
||||||
};
|
};
|
||||||
@@ -140,6 +142,10 @@ export default function AdminApp() {
|
|||||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
|
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
|
||||||
notify('Passwort gesetzt.');
|
notify('Passwort gesetzt.');
|
||||||
};
|
};
|
||||||
|
const setUserPrivate = async (id: string, v: boolean) => {
|
||||||
|
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
|
||||||
|
load();
|
||||||
|
};
|
||||||
const addUser = async () => {
|
const addUser = async () => {
|
||||||
const username = prompt('Benutzername?'); if (!username) return;
|
const username = prompt('Benutzername?'); if (!username) return;
|
||||||
const password = prompt('Passwort?'); if (!password) return;
|
const password = prompt('Passwort?'); if (!password) return;
|
||||||
@@ -306,6 +312,25 @@ export default function AdminApp() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="karte">
|
||||||
|
<div className="kopfzeile"><span className="mono-label">Rechte & Datenschutz</span></div>
|
||||||
|
<div className="steuer">
|
||||||
|
<div className="feld"><label>Wer sieht welche Generierungen?</label>
|
||||||
|
<div className="schalter">
|
||||||
|
<button className={s.library_visibility !== 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'own')}>Nur eigene</button>
|
||||||
|
<button className={s.library_visibility === 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'shared')}>Alle sehen alles</button>
|
||||||
|
</div>
|
||||||
|
<div className="fein">Admins sehen immer alles. „Nur eigene": jede/r sieht ausschließlich die selbst erzeugten Bilder.</div>
|
||||||
|
</div>
|
||||||
|
<label className="schalt"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
|
||||||
|
<span><b>Generierungen anonym</b><em>Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.</em></span></label>
|
||||||
|
<label className="schalt"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
|
||||||
|
<span><b>Private Sessions erlauben</b><em>Nutzer bekommen im Studio den Schalter „Private Session" (nur erzeugen, danach vergessen).</em></span></label>
|
||||||
|
<label className="schalt"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
|
||||||
|
<span><b>NSFW-Modelle erlauben</b><em>Als NSFW markierte Modelle werden wähl-/sichtbar. Verantwortung liegt beim Betreiber; Inhalte mit Minderjährigen sind ausnahmslos untersagt.</em></span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
|
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
|
||||||
|
|
||||||
<section className="karte">
|
<section className="karte">
|
||||||
@@ -313,9 +338,10 @@ export default function AdminApp() {
|
|||||||
<div className="liste">
|
<div className="liste">
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<div key={m.id} className="zeile">
|
<div key={m.id} className="zeile">
|
||||||
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}<div className="fein">{m.model_id}</div></div>
|
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}{m.nsfw && <span className="pille nsfw">NSFW</span>}<div className="fein">{m.model_id}</div></div>
|
||||||
<div className="reihe">
|
<div className="reihe">
|
||||||
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
|
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
|
||||||
|
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? 'NSFW aus' : 'NSFW'}</button>
|
||||||
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
|
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,8 +360,9 @@ export default function AdminApp() {
|
|||||||
<div className="liste">
|
<div className="liste">
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<div key={u.id} className="zeile">
|
<div key={u.id} className="zeile">
|
||||||
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div>
|
<div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pille">privat</span>} <span className="fein">{u.username} · {u.role}</span></div>
|
||||||
<div className="reihe">
|
<div className="reihe">
|
||||||
|
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? 'Privat aus' : 'Privat erzwingen'}</button>
|
||||||
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
|
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
|
||||||
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
|
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +425,7 @@ function AdminStyles() {
|
|||||||
.zeile b{font-size:14px;}
|
.zeile b{font-size:14px;}
|
||||||
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
|
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
|
||||||
.pille.alpha{background:var(--paper);color:var(--soft);}
|
.pille.alpha{background:var(--paper);color:var(--soft);}
|
||||||
|
.pille.nsfw{background:var(--err);color:#fff;}
|
||||||
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
||||||
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||||
.schalt b{display:block;font-size:14px;}
|
.schalt b{display:block;font-size:14px;}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
const [targetId, setTargetId] = useState('');
|
const [targetId, setTargetId] = useState('');
|
||||||
const [targets, setTargets] = useState<any[]>([]);
|
const [targets, setTargets] = useState<any[]>([]);
|
||||||
const [presetSel, setPresetSel] = useState('');
|
const [presetSel, setPresetSel] = useState('');
|
||||||
|
const [me, setMe] = useState<any>({});
|
||||||
|
const [priv, setPriv] = useState(false);
|
||||||
const [models, setModels] = useState<any[]>([]);
|
const [models, setModels] = useState<any[]>([]);
|
||||||
const [modelKey, setModelKey] = useState<string>('');
|
const [modelKey, setModelKey] = useState<string>('');
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
@@ -119,6 +121,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
if (def) setModelKey(def.model_id);
|
if (def) setModelKey(def.model_id);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||||
|
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
|
||||||
loadPresets();
|
loadPresets();
|
||||||
}, []);
|
}, []);
|
||||||
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
||||||
@@ -208,7 +211,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
|
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
|
||||||
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
|
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
|
||||||
};
|
};
|
||||||
const body: any = { recipe, mode, delivery };
|
const body: any = { recipe, mode, delivery, private: priv };
|
||||||
if (mode !== 'each') body.prompt_text = desc.trim();
|
if (mode !== 'each') body.prompt_text = desc.trim();
|
||||||
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
||||||
try {
|
try {
|
||||||
@@ -395,6 +398,14 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(me.private_allowed || me.private_forced) && (
|
||||||
|
<label className="privat">
|
||||||
|
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
|
||||||
|
<span><b>Private Session {me.private_forced ? '(erzwungen)' : ''}</b>
|
||||||
|
<em>Nur erzeugen, nichts merken: kein Eintrag in der Bibliothek, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Ergebnis kurz danach abrufbar, dann gelöscht.</em></span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<button className="knopf" disabled={!canRun} onClick={run}>
|
<button className="knopf" disabled={!canRun} onClick={run}>
|
||||||
{busy ? <><span className="spin" />Wird angelegt …</>
|
{busy ? <><span className="spin" />Wird angelegt …</>
|
||||||
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
|
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
|
||||||
@@ -461,6 +472,10 @@ function StudioStyles() {
|
|||||||
.reihe-presets{display:flex;gap:6px;align-items:center;}
|
.reihe-presets{display:flex;gap:6px;align-items:center;}
|
||||||
.reihe-presets .select{flex:1;}
|
.reihe-presets .select{flex:1;}
|
||||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||||
|
.privat{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
|
||||||
|
.privat input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||||
|
.privat b{display:block;font-size:13.5px;}
|
||||||
|
.privat em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
|
||||||
.schalter.wrap{flex-wrap:wrap;}
|
.schalter.wrap{flex-wrap:wrap;}
|
||||||
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
|
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
|
||||||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||||
|
|||||||
+18
-2
@@ -76,8 +76,22 @@ export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: numb
|
|||||||
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Private Aufträge „vergessen": Ergebnisse + Aufträge nach kurzer Zeit löschen. */
|
||||||
|
export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number }> {
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT i.id, i.result_path, i.thumb_path FROM items i JOIN jobs j ON j.id=i.job_id
|
||||||
|
WHERE j.private=true AND i.created_at < now() - ($1 || ' minutes')::interval`, [String(olderThanMin)]);
|
||||||
|
for (const it of rows) {
|
||||||
|
for (const k of [it.result_path, it.thumb_path].filter(Boolean)) await deleteObject(k).catch(() => {});
|
||||||
|
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||||
|
}
|
||||||
|
// leere private Aufträge entfernen
|
||||||
|
await query(`DELETE FROM jobs WHERE private=true AND id NOT IN (SELECT DISTINCT job_id FROM items WHERE job_id IS NOT NULL)`);
|
||||||
|
return { deleted: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
/** Täglicher Retention-Lauf (falls in den Einstellungen aktiviert). */
|
/** Wartungs-Lauf: Retention + private Sessions vergessen. */
|
||||||
export function startMaintenance(): void {
|
export function startMaintenance(): void {
|
||||||
if (timer) return;
|
if (timer) return;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
@@ -87,8 +101,10 @@ export function startMaintenance(): void {
|
|||||||
const r = await runRetention(s.retention_days);
|
const r = await runRetention(s.retention_days);
|
||||||
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
|
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
|
||||||
}
|
}
|
||||||
|
const p = await purgePrivate(60);
|
||||||
|
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
|
||||||
} catch (e) { console.error('[maintenance]', e); }
|
} catch (e) { console.error('[maintenance]', e); }
|
||||||
};
|
};
|
||||||
timer = setInterval(tick, 6 * 60 * 60 * 1000); // alle 6 h
|
timer = setInterval(tick, 30 * 60 * 1000); // alle 30 min (auch für private Purge)
|
||||||
setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min
|
setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-9
@@ -61,10 +61,11 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
|||||||
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
||||||
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
||||||
|
|
||||||
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode }>(
|
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||||
'SELECT recipe_snapshot, mode FROM jobs WHERE id=$1', [item.job_id]);
|
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||||
const mode: Mode = job?.mode || 'each';
|
const mode: Mode = job?.mode || 'each';
|
||||||
|
const isPrivate = !!job?.private;
|
||||||
const tasks = r.tasks || [];
|
const tasks = r.tasks || [];
|
||||||
const dpi = r.dpi ?? 300;
|
const dpi = r.dpi ?? 300;
|
||||||
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
||||||
@@ -146,26 +147,30 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
|||||||
|
|
||||||
const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
|
const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
|
||||||
const outputPx = `${fin.width}x${fin.height}`;
|
const outputPx = `${fin.width}x${fin.height}`;
|
||||||
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
|
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
|
||||||
const nasStatus = cfg?.nas_enabled ? 'pending' : 'none';
|
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
|
||||||
|
const nasStatus = (!isPrivate && cfg?.nas_enabled) ? 'pending' : 'none';
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
||||||
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
||||||
delivery_status=$11, nas_status=$12 WHERE id=$1`,
|
delivery_status=$11, nas_status=$12 WHERE id=$1`,
|
||||||
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
|
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed,
|
||||||
cost, deliveryStatus, nasStatus]);
|
isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, nasStatus]);
|
||||||
|
|
||||||
// Quellbilder löschen, wenn nicht behalten (Speicher sparen).
|
// Quellbilder löschen, wenn nicht behalten — bei privaten Aufträgen immer.
|
||||||
if (cfg?.keep_sources === false) {
|
if (isPrivate || cfg?.keep_sources === false) {
|
||||||
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
||||||
|
if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zweite Sicherung auf alle Backup-Ziele (NAS + is_backup-Ziele), best effort.
|
// Zweite Sicherung auf alle Backup-Ziele — nicht bei privaten Aufträgen.
|
||||||
|
if (!isPrivate) {
|
||||||
try {
|
try {
|
||||||
const { backupItem } = await import('./backup');
|
const { backupItem } = await import('./backup');
|
||||||
await backupItem(itemId);
|
await backupItem(itemId);
|
||||||
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
|
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, cost };
|
return { ok: true, cost };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
|
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (b.action === 'nsfw' && b.id) {
|
||||||
|
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400);
|
if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400);
|
||||||
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
|
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const GET: APIRoute = async () => {
|
|||||||
// Speicherverwaltung
|
// Speicherverwaltung
|
||||||
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
|
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
|
||||||
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
||||||
|
// Rechte / Datenschutz / NSFW
|
||||||
|
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
|
||||||
|
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
|
||||||
// NAS-Sicherung
|
// NAS-Sicherung
|
||||||
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
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,
|
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
|
||||||
@@ -39,10 +42,12 @@ export const PATCH: APIRoute = async ({ request }) => {
|
|||||||
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
|
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)));
|
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
|
||||||
if (b.generate_api_token) set('api_token', randomToken());
|
if (b.generate_api_token) set('api_token', randomToken());
|
||||||
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar'];
|
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
|
||||||
|
'anonymous_generations', 'private_allowed', 'allow_nsfw'];
|
||||||
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
|
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',
|
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||||
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||||
|
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
|
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
|
||||||
if (col in b) {
|
if (col in b) {
|
||||||
const raw = b[col];
|
const raw = b[col];
|
||||||
|
|||||||
@@ -7,17 +7,25 @@ const json = (b: unknown, s = 200) =>
|
|||||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
const users = await query('SELECT id, username, role, display_name, created_at FROM users ORDER BY username');
|
const users = await query('SELECT id, username, role, display_name, private_forced, created_at FROM users ORDER BY username');
|
||||||
return json({ users });
|
return json({ users });
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST: neuen Nutzer anlegen ODER Passwort setzen (bei vorhandenem username/id).
|
// POST: neuen Nutzer anlegen ODER Passwort/Attribute setzen (bei vorhandenem username/id).
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
const b = await request.json();
|
const b = await request.json();
|
||||||
if (b.id && b.password) {
|
if (b.id && b.password) {
|
||||||
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
|
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (b.id && 'private_forced' in b) {
|
||||||
|
await query('UPDATE users SET private_forced=$2 WHERE id=$1', [b.id, !!b.private_forced]);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (b.id && b.role) {
|
||||||
|
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
|
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
|
||||||
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
|
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
|
||||||
if (exists) return json({ error: 'Benutzername vergeben.' }, 400);
|
if (exists) return json({ error: 'Benutzername vergeben.' }, 400);
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
|
|||||||
const wantThumb = q.get('thumb') === '1';
|
const wantThumb = q.get('thumb') === '1';
|
||||||
const wantSrc = q.get('src') === '1';
|
const wantSrc = q.get('src') === '1';
|
||||||
const item = await one<any>(
|
const item = await one<any>(
|
||||||
`SELECT source_path, source_paths, result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1`, [params.id]);
|
`SELECT i.source_path, i.source_paths, i.result_path, i.thumb_path, i.filename, i.has_alpha,
|
||||||
|
j.created_by, j.private FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [params.id]);
|
||||||
|
if (item) {
|
||||||
|
const isAdmin = locals.user.role === 'admin';
|
||||||
|
const own = item.created_by === locals.user.uid;
|
||||||
|
if (!isAdmin && !own) {
|
||||||
|
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||||
|
if (item.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Quelle: Einzelquelle, sonst erste Kombinier-Quelle (für Vorher/Nachher).
|
// Quelle: Einzelquelle, sonst erste Kombinier-Quelle (für Vorher/Nachher).
|
||||||
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
|
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
|
||||||
const path = wantSrc ? firstSource
|
const path = wantSrc ? firstSource
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { query } from '../../../lib/db';
|
import { one, query } from '../../../lib/db';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -9,9 +9,14 @@ const json = (b: unknown, s = 200) =>
|
|||||||
export const GET: APIRoute = async ({ url, locals }) => {
|
export const GET: APIRoute = async ({ url, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const q = url.searchParams;
|
const q = url.searchParams;
|
||||||
const where: string[] = [`i.status='done'`];
|
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||||
|
const isAdmin = locals.user.role === 'admin';
|
||||||
|
const shared = s?.library_visibility === 'shared';
|
||||||
|
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung.
|
||||||
|
const where: string[] = [`i.status='done'`, `j.private=false`];
|
||||||
const args: any[] = [];
|
const args: any[] = [];
|
||||||
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
||||||
|
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
||||||
|
|
||||||
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
|
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
|
||||||
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
|
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
|
||||||
@@ -24,7 +29,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|||||||
|
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id,
|
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id,
|
||||||
i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode, u.display_name AS by_name,
|
i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode,
|
||||||
|
${(s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name'} AS by_name,
|
||||||
j.recipe_snapshot->'tasks' AS tasks
|
j.recipe_snapshot->'tasks' AS tasks
|
||||||
FROM items i
|
FROM items i
|
||||||
JOIN jobs j ON j.id = i.job_id
|
JOIN jobs j ON j.id = i.job_id
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ params, locals }) => {
|
export const GET: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const job = await one('SELECT * FROM jobs WHERE id=$1', [params.id]);
|
const job = await one<any>('SELECT * FROM jobs WHERE id=$1', [params.id]);
|
||||||
if (!job) return json({ error: 'Nicht gefunden.' }, 404);
|
if (!job) return json({ error: 'Nicht gefunden.' }, 404);
|
||||||
|
// Zugriff: eigener Auftrag, Admin, oder shared (nicht privat).
|
||||||
|
const isAdmin = locals.user.role === 'admin';
|
||||||
|
const own = job.created_by === locals.user.uid;
|
||||||
|
if (!isAdmin && !own) {
|
||||||
|
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||||
|
if (job.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
const items = await query(
|
const items = await query(
|
||||||
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
|
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
|
||||||
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
|
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ locals }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||||
|
const isAdmin = locals.user.role === 'admin';
|
||||||
|
const shared = s?.library_visibility === 'shared';
|
||||||
|
const where: string[] = ['j.private=false']; const args: any[] = [];
|
||||||
|
if (!isAdmin && !shared) { args.push(locals.user.uid); where.push(`j.created_by=$${args.length}`); }
|
||||||
|
const nameCol = (s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name';
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`SELECT j.*, u.display_name AS by_name FROM jobs j
|
`SELECT j.*, ${nameCol} AS by_name FROM jobs j
|
||||||
LEFT JOIN users u ON u.id=j.created_by ORDER BY j.created_at DESC LIMIT 50`);
|
LEFT JOIN users u ON u.id=j.created_by
|
||||||
|
WHERE ${where.join(' AND ')} ORDER BY j.created_at DESC LIMIT 50`, args);
|
||||||
return json({ jobs: rows });
|
return json({ jobs: rows });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,11 +59,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
delivery_target_id: snapshot.delivery_target_id ?? null,
|
delivery_target_id: snapshot.delivery_target_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Private Session: nur wenn global erlaubt oder für den Nutzer erzwungen.
|
||||||
|
const acc = await one<any>(`SELECT s.private_allowed, u.private_forced
|
||||||
|
FROM settings s, users u WHERE s.id=1 AND u.id=$1`, [locals.user.uid]);
|
||||||
|
const isPrivate = !!(acc?.private_forced || (acc?.private_allowed && b.private));
|
||||||
|
if (isPrivate) snap.delivery = 'library'; // kein externes Delivery bei privaten Aufträgen
|
||||||
|
|
||||||
const total = (mode === 'each') ? sources.length : 1;
|
const total = (mode === 'each') ? sources.length : 1;
|
||||||
const job = await one<{ id: string }>(
|
const job = await one<{ id: string }>(
|
||||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total)
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, private)
|
||||||
VALUES ($1,$2,$3,$4,'queued',$5) RETURNING id`,
|
VALUES ($1,$2,$3,$4,'queued',$5,$6) RETURNING id`,
|
||||||
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total]);
|
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total, isPrivate]);
|
||||||
|
|
||||||
if (mode === 'each') {
|
if (mode === 'each') {
|
||||||
for (let i = 0; i < sources.length; i++) {
|
for (let i = 0; i < sources.length; i++) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { one } from '../../lib/db';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
|
||||||
|
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
role: locals.user.role,
|
||||||
|
private_allowed: !!s?.private_allowed,
|
||||||
|
private_forced: !!u?.private_forced,
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { query } from '../../lib/db';
|
import { one, query } from '../../lib/db';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
|
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
|
||||||
|
// NSFW-fähige Modelle nur, wenn allow_nsfw in den Einstellungen aktiv ist.
|
||||||
export const GET: APIRoute = async ({ locals }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const s = await one<{ allow_nsfw: boolean }>('SELECT allow_nsfw FROM settings WHERE id=1');
|
||||||
const models = await query(
|
const models = await query(
|
||||||
`SELECT model_id, label, description, is_default, supports_alpha FROM models
|
`SELECT model_id, label, description, is_default, supports_alpha, nsfw FROM models
|
||||||
WHERE active ORDER BY sort, label`);
|
WHERE active AND (nsfw=false OR $1=true) ORDER BY sort, label`, [!!s?.allow_nsfw]);
|
||||||
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user