feat: Bibliothek + Admin

- Bibliothek: grid, before/after slider, rename, download, folder assign, delete, multi-select
- Admin: stats+costs, OpenRouter key (masked/encrypted), budget, defaults, Picdrop config+test,
  models (list/add from OpenRouter/set default/delete), users (add/set password)
- api: items/:id PATCH+DELETE, folders CRUD, admin/{settings,stats,models,test-picdrop,users}
- lib/picdrop.ts (SFTP/FTPS test+upload, temp->rename)
This commit is contained in:
2026-07-23 15:22:12 +00:00
parent dce7515c9b
commit 25d0a5cd97
13 changed files with 621 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
// Picdrop-Auslieferung via SFTP (ssh2-sftp-client) oder FTPS (basic-ftp).
// Konfiguration kommt aus settings (verschlüsseltes Passwort).
import posixpath from 'node:path/posix';
import { one } from './db';
import { decrypt } from './crypto';
export interface PicdropCfg {
host: string; protocol: 'ftps' | 'sftp'; port: number;
user: string; password: string; basePath: string;
}
export async function loadConfig(): Promise<PicdropCfg | null> {
const s = await one<any>(`SELECT picdrop_host, picdrop_protocol, picdrop_port, picdrop_user,
picdrop_password_enc, picdrop_base_path FROM settings WHERE id=1`);
if (!s?.picdrop_host || !s?.picdrop_user || !s?.picdrop_password_enc) return null;
let password = '';
try { password = decrypt(s.picdrop_password_enc); } catch { return null; }
return {
host: s.picdrop_host, protocol: (s.picdrop_protocol || 'sftp'),
port: s.picdrop_port || (s.picdrop_protocol === 'ftps' ? 21 : 22),
user: s.picdrop_user, password, basePath: s.picdrop_base_path || '/',
};
}
/** Verbindung testen: verbinden + Basisordner listen. */
export async function testConnection(cfg: PicdropCfg): 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: 'Verbindung erfolgreich.' };
} catch (e: any) {
return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' };
}
}
/** Lädt einen Puffer als Datei hoch (temp-Name → umbenennen, kein .filepart). */
export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
const finalPath = posixpath.join(dir, filename);
const tmpPath = posixpath.join(dir, `.tmp-${Date.now()}-${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, tmpPath);
await c.rename(tmpPath, 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), tmpPath);
await c.rename(tmpPath, finalPath);
} finally { c.close(); }
}
}