commit 8baba7b51f3d89086ac13e6f85f4ba64a2c28485 Author: Dicken Date: Sat Jul 18 23:50:32 2026 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff346ca --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Kopiere diese Datei zu .env und passe die Werte an +# .env wird NICHT ins Git eingecheckt + +JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaf8d24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.db +*.db-bak +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6bb801d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# ── Stage 1: React bauen ───────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /build +COPY frontend/package.json ./ +RUN npm install +COPY frontend/ ./ +# version.txt VOR dem Build generieren – Unix-Timestamp (Sekunden) +# Dieser Wert wird von vite.config.js als __BUILD_TIME__ ins Bundle gebacken +# UND vom Backend als /api/build-time ausgeliefert → exakt derselbe Wert +RUN date -u +"%s" > ./version.txt +# Build-Version in sw.js einsetzen damit der Browser bei jedem Deploy einen neuen SW erkennt +RUN BUILD_V=$(cat ./version.txt) && sed -i "s/__BUILD_VERSION__/${BUILD_V}/g" ./public/sw.js +RUN npm run build + +# ── Stage 2: Express liefert API + React in einem Container ────────────────── +FROM node:20-alpine +# Puppeteer/Chromium für KöPi Web-Scraping +RUN apk add --no-cache tzdata python3 make g++ chromium nss freetype harfbuzz ca-certificates ttf-freefont +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +# Zeitzone fest auf Europe/Berlin setzen, damit Server und Browser (DE) immer +# dieselbe lokale Zeit berechnen – inkl. automatischer Sommer-/Winterzeit-Umstellung +ENV TZ=Europe/Berlin +RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +WORKDIR /app +COPY backend/package.json ./ +RUN npm install --production +COPY backend/ ./ +# version.txt aus dem Build-Stage kopieren (selber Wert wie im JS-Bundle) +COPY --from=builder /build/version.txt ./version.txt +COPY --from=builder /build/dist ./public +EXPOSE 4000 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f7007f5 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,15 @@ +{ + "name": "dickendock-backend", + "version": "1.0.0", + "main": "src/index.js", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "ws": "^8.17.1", + "puppeteer-core": "^22.0.0", + "mqtt": "^5.10.1" + } +} \ No newline at end of file diff --git a/backend/src/consoleLog.js b/backend/src/consoleLog.js new file mode 100644 index 0000000..9dc4a88 --- /dev/null +++ b/backend/src/consoleLog.js @@ -0,0 +1,63 @@ +const fs = require('fs'); + +// Persistenter Pfad (selbes Volume wie die DB), übersteht Container-Neustarts +const LOG_FILE = process.env.CONSOLE_LOG_FILE || '/data/app-console.log'; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; + +const origLog = console.log.bind(console); +const origError = console.error.bind(console); +const origWarn = console.warn.bind(console); + +function stringifyArg(a) { + if (typeof a === 'string') return a; + if (a instanceof Error) return a.stack || a.message; + try { return JSON.stringify(a); } catch { return String(a); } +} + +function localTimestamp() { + const d = new Date(); + const pad = (n, len=2) => String(n).padStart(len, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(),3)}`; +} + +function appendLine(level, args) { + try { + const msg = args.map(stringifyArg).join(' '); + fs.appendFileSync(LOG_FILE, `[${localTimestamp()}] [${level}] ${msg}\n`); + } catch { + // Darf niemals die eigentliche Konsolen-Ausgabe verhindern + } +} + +// Entfernt Zeilen, die älter als MAX_AGE_MS sind. Zeilen ohne erkennbaren +// Zeitstempel (z.B. Fortsetzungszeilen eines mehrzeiligen Stacktraces) werden +// bewusst behalten, um nichts mittendrin abzuschneiden. +function pruneOldLines() { + try { + if (!fs.existsSync(LOG_FILE)) return; + const content = fs.readFileSync(LOG_FILE, 'utf8'); + const cutoff = Date.now() - MAX_AGE_MS; + const lines = content.split('\n').filter(line => { + const m = line.match(/^\[([^\]]+)\]/); + if (!m) return true; + const t = new Date(m[1].replace(' ', 'T')).getTime(); + return Number.isNaN(t) || t >= cutoff; + }); + fs.writeFileSync(LOG_FILE, lines.join('\n')); + } catch (e) { + origError('Konsolen-Log-Aufräumen fehlgeschlagen:', e.message); + } +} + +function installConsoleCapture() { + console.log = (...args) => { origLog(...args); appendLine('LOG', args); }; + console.error = (...args) => { origError(...args); appendLine('ERROR', args); }; + console.warn = (...args) => { origWarn(...args); appendLine('WARN', args); }; + + pruneOldLines(); // einmal direkt beim Start + setInterval(pruneOldLines, 60 * 60 * 1000); // danach stündlich +} + +function getLogFilePath() { return LOG_FILE; } + +module.exports = { installConsoleCapture, getLogFilePath, pruneOldLines }; diff --git a/backend/src/crypto-helper.js b/backend/src/crypto-helper.js new file mode 100644 index 0000000..b186569 --- /dev/null +++ b/backend/src/crypto-helper.js @@ -0,0 +1,36 @@ +// ── Verschlüsselungs-Helper für sensible gespeicherte Zugangsdaten ────────── +// Nutzt AES-256-GCM. Der Schlüssel wird aus JWT_SECRET abgeleitet (kein +// zusätzlicher Env-Var nötig) — ausreichend für den Zweck hier (Zugangsdaten +// liegen nicht mehr im Klartext in der SQLite-DB), ersetzt aber kein +// dediziertes Secret-Management für hochsensible Fälle. + +const crypto = require('crypto'); + +const KEY = crypto.createHash('sha256') + .update(process.env.JWT_SECRET || 'dickendock-fallback-key-bitte-JWT_SECRET-setzen') + .digest(); + +function encrypt(text) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv); + const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +function decrypt(b64) { + if (!b64) return null; + try { + const buf = Buffer.from(b64, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const enc = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); + } catch { + return null; // falsches/verändertes Secret oder korrupte Daten + } +} + +module.exports = { encrypt, decrypt }; diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..856b492 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,710 @@ +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const path = require('path'); + +const db = new Database(process.env.DB_PATH || '/data/dickendock.db'); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Dashboard-Widgets + CREATE TABLE IF NOT EXISTS quick_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🔗', + sort_order INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Tool: 3D-Kalkulator + CREATE TABLE IF NOT EXISTS calendar_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4ecdc4', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS folder_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(folder_id, shared_with) + ); + + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + originalname TEXT NOT NULL, + mimetype TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS file_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(file_id, shared_with) + ); + CREATE TABLE IF NOT EXISTS admin_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + bemerkung TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'warteliste', + custom_price REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + calculation_id INTEGER REFERENCES calculations(id) ON DELETE SET NULL, + calc_name TEXT NOT NULL, + preis_freundschaft REAL NOT NULL, + preis_normal REAL NOT NULL, + preis_auftrag REAL NOT NULL, + stueckzahl INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS calculations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + gramm REAL NOT NULL, + stunden REAL NOT NULL, + farben INTEGER NOT NULL DEFAULT 1, + materialpreis_pro_gramm REAL NOT NULL DEFAULT 0.01, + stromverbrauch_kw REAL NOT NULL DEFAULT 0.15, + strompreis_pro_kwh REAL NOT NULL DEFAULT 0.38, + druckerpreis REAL NOT NULL DEFAULT 550, + gesamtdruckstunden REAL NOT NULL DEFAULT 5000, + verschleiss_pro_stunde REAL NOT NULL DEFAULT 0.06, + preis_freundschaft REAL NOT NULL, + preis_normal REAL NOT NULL, + preis_auftrag REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +// Standard-Admin beim ersten Start anlegen +// Default admin settings +const settingDefaults = [ + ['file_max_size_mb', '50'], + ['file_max_count', '20'], + ['file_allowed_ext', '.pdf,.jpg,.jpeg,.png,.gif,.zip,.txt,.docx,.xlsx,.mp4,.stl,.3mf'], +]; +for (const [key, value] of settingDefaults) { + if (!db.prepare('SELECT key FROM admin_settings WHERE key=?').get(key)) + db.prepare('INSERT INTO admin_settings (key,value) VALUES (?,?)').run(key, value); +} + +if (db.prepare('SELECT COUNT(*) c FROM users').get().c === 0) { + db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + .run('admin', bcrypt.hashSync('admin123', 12), 'admin'); + console.log('✅ Erster Start: Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!'); +} + +// ── Migrationen (werden bei jedem Start geprüft) ───────────────────────────── +const cols = db.prepare("PRAGMA table_info(calculations)").all().map(r => r.name); +if (!cols.includes('image')) db.exec("ALTER TABLE calculations ADD COLUMN image TEXT"); +if (!cols.includes('bemerkung')) db.exec("ALTER TABLE calculations ADD COLUMN bemerkung TEXT NOT NULL DEFAULT ''"); + +// Migrationen orders +const ordCols = db.prepare("PRAGMA table_info(orders)").all().map(r => r.name); +if (!ordCols.includes('bezahlt')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt INTEGER NOT NULL DEFAULT 0"); +if (!ordCols.includes('bezahlt_am')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt_am DATETIME"); +if (!ordCols.includes('abgeholt')) db.exec("ALTER TABLE orders ADD COLUMN abgeholt INTEGER NOT NULL DEFAULT 0"); +if (!ordCols.includes('abgeholt_am'))db.exec("ALTER TABLE orders ADD COLUMN abgeholt_am DATETIME"); + +// Migrationen order_items +// files migrations +const fileCols = db.prepare("PRAGMA table_info(files)").all().map(r => r.name); +if (!fileCols.includes('folder_id')) db.exec("ALTER TABLE files ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL"); + +// folder_shares migrations (only if table exists) +const fsTableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='folder_shares'").get(); +if (fsTableExists) { + const fsCols = db.prepare("PRAGMA table_info(folder_shares)").all().map(r => r.name); + if (!fsCols.includes('created_at')) db.exec("ALTER TABLE folder_shares ADD COLUMN created_at DATETIME DEFAULT NULL"); +} + +const oiCols = db.prepare("PRAGMA table_info(order_items)").all().map(r => r.name); +if (!oiCols.includes('stunden')) db.exec("ALTER TABLE order_items ADD COLUMN stunden REAL NOT NULL DEFAULT 0"); +if (!oiCols.includes('custom_price')) db.exec("ALTER TABLE order_items ADD COLUMN custom_price REAL"); +if (!oiCols.includes('status')) db.exec("ALTER TABLE order_items ADD COLUMN status TEXT NOT NULL DEFAULT 'warteliste'"); +if (!oiCols.includes('qty_warteliste')) db.exec("ALTER TABLE order_items ADD COLUMN qty_warteliste INTEGER NOT NULL DEFAULT 0"); +if (!oiCols.includes('qty_in_arbeit')) db.exec("ALTER TABLE order_items ADD COLUMN qty_in_arbeit INTEGER NOT NULL DEFAULT 0"); +if (!oiCols.includes('qty_fertig')) db.exec("ALTER TABLE order_items ADD COLUMN qty_fertig INTEGER NOT NULL DEFAULT 0"); +// Bestehende Einträge: alle Stücke auf Warteliste setzen falls noch keine Qtys gesetzt +db.exec("UPDATE order_items SET qty_warteliste=stueckzahl WHERE qty_warteliste=0 AND qty_in_arbeit=0 AND qty_fertig=0"); + +// Migration: file_shares – Passwort und Zugriffszeit +const fShareCols = db.prepare("PRAGMA table_info(file_shares)").all().map(r => r.name); +if (fShareCols.length && !fShareCols.includes('password_hash')) + db.exec("ALTER TABLE file_shares ADD COLUMN password_hash TEXT DEFAULT NULL"); +if (fShareCols.length && !fShareCols.includes('accessed_at')) + db.exec("ALTER TABLE file_shares ADD COLUMN accessed_at DATETIME DEFAULT NULL"); + +// ── Nachrichten-Migrationen ─────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get()) { + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + encrypted_content TEXT NOT NULL, + iv TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + read_by_recipient INTEGER NOT NULL DEFAULT 0 + ) + `); +} +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_keys'").get()) { + db.exec(` + CREATE TABLE user_keys ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + updated_at DATETIME DEFAULT NULL + ) + `); +} +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pushover_settings'").get()) { + db.exec(` + CREATE TABLE pushover_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + user_key TEXT NOT NULL, + app_token TEXT NOT NULL + ) + `); +} +// Migration: Soft-Delete-Spalten entfernen (nur Nachrichten übernehmen die noch sichtbar waren) +const msgCols = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name); +if (msgCols.includes('deleted_by_sender')) { + db.exec(` + CREATE TABLE messages_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + encrypted_content TEXT NOT NULL, + iv TEXT NOT NULL, + created_at DATETIME DEFAULT NULL, + read_by_recipient INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO messages_v2 (id, sender_id, recipient_id, encrypted_content, iv, created_at, read_by_recipient) + SELECT id, sender_id, recipient_id, encrypted_content, iv, created_at, + COALESCE(read_by_recipient, 0) + FROM messages + WHERE deleted_by_sender = 0 AND deleted_by_recipient = 0; + DROP TABLE messages; + ALTER TABLE messages_v2 RENAME TO messages; + `); + console.log('✅ Migration: messages → Hard-Delete (Soft-Delete-Spalten entfernt)'); +} +// Falls read_by_recipient noch fehlt (sehr alte Installation) +const msgCols2 = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name); +if (msgCols2.length && !msgCols2.includes('read_by_recipient')) { + db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0"); +} + +// User-Preferences (JSON-Blob) +const userCols2 = db.prepare("PRAGMA table_info(users)").all().map(r => r.name); +if (!userCols2.includes('preferences')) + db.exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT NULL"); +if (!userCols2.includes('last_active_at')) + db.exec("ALTER TABLE users ADD COLUMN last_active_at DATETIME DEFAULT NULL"); +if (!userCols2.includes('hidden')) + db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"); +if (!userCols2.includes('chat_active_at')) + db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL"); +if (!userCols2.includes('hidden_tools')) + db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'"); + +// ── Login-Sicherheit ────────────────────────────────────────────────────────── +// Spalten für Account-Lockout in users-Tabelle +const userCols = db.prepare("PRAGMA table_info(users)").all().map(r => r.name); +if (!userCols.includes('failed_attempts')) + db.exec("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0"); +if (!userCols.includes('locked_until')) + db.exec("ALTER TABLE users ADD COLUMN locked_until DATETIME DEFAULT NULL"); +if (!userCols.includes('last_failed_at')) + db.exec("ALTER TABLE users ADD COLUMN last_failed_at DATETIME DEFAULT NULL"); + +// Login-Fehlversuche Log +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='login_attempts'").get()) { + db.exec(` + CREATE TABLE login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip TEXT, + success INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} + +// Default-Einstellungen für Login-Schutz +const loginDefaults = [ + ['login_max_attempts', '5'], + ['login_lockout_minutes', '30'], +]; +for (const [k, v] of loginDefaults) { + if (!db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)) + db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v); +} + +// ── Ideen-Board ─────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) { + db.exec(` + CREATE TABLE board_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('roadmap','wish')), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + promoted_from_wish INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} +// Migration: promoted_from_wish Spalte +const boardCols = db.prepare("PRAGMA table_info(board_items)").all().map(r => r.name); +if (boardCols.length && !boardCols.includes('promoted_from_wish')) + db.exec("ALTER TABLE board_items ADD COLUMN promoted_from_wish INTEGER NOT NULL DEFAULT 0"); +if (boardCols.length && !boardCols.includes('promoted_at')) + db.exec("ALTER TABLE board_items ADD COLUMN promoted_at DATETIME DEFAULT NULL"); +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) { + db.exec(` + CREATE TABLE board_reads ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_read DATETIME DEFAULT NULL + ) + `); +} + +// Migration: Pushover retry/expire +const poCols = db.prepare("PRAGMA table_info(pushover_settings)").all().map(r => r.name); +if (poCols.length && !poCols.includes('retry')) + db.exec("ALTER TABLE pushover_settings ADD COLUMN retry INTEGER DEFAULT NULL"); +if (poCols.length && !poCols.includes('expire')) + db.exec("ALTER TABLE pushover_settings ADD COLUMN expire INTEGER DEFAULT NULL"); + +// ── Changelog ───────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog'").get()) { + db.exec(` + CREATE TABLE changelog ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + build_time TEXT DEFAULT NULL, + created_at DATETIME DEFAULT NULL + ) + `); +} +// Migration: build_time Spalte +const clCols = db.prepare("PRAGMA table_info(changelog)").all().map(r => r.name); +if (clCols.length && !clCols.includes('build_time')) + db.exec("ALTER TABLE changelog ADD COLUMN build_time TEXT DEFAULT NULL"); + +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog_reads'").get()) { + db.exec(` + CREATE TABLE changelog_reads ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_read DATETIME DEFAULT NULL + ) + `); +} + +// ── Push-Zeitplaner ─────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) { + db.exec(` + CREATE TABLE push_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT NOT NULL, + scheduled_at DATETIME NOT NULL, + sent INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} + +// ── Link-Liste ──────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) { + db.exec(` + CREATE TABLE link_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🔗', + description TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT NULL + ) + `); +} +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_shares'").get()) { + db.exec(` + CREATE TABLE link_list_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id INTEGER NOT NULL REFERENCES link_list(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT NULL, + UNIQUE(link_id, shared_with) + ) + `); +} + +// ── Kalkulator-Shares ───────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) { + db.exec(` + CREATE TABLE calculation_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + calc_id INTEGER NOT NULL REFERENCES calculations(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT NULL, + UNIQUE(calc_id, shared_with) + ) + `); +} + +// ── Code-Schnipsel ──────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='snippets'").get()) { + db.exec(` + CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + code TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'text', + description TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT NULL, + updated_at DATETIME DEFAULT NULL + ); + CREATE TABLE snippet_tags ( + snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (snippet_id, tag) + ); + CREATE TABLE snippet_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, + code TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'text', + saved_at DATETIME DEFAULT NULL + ); + CREATE TABLE snippet_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT NULL, + UNIQUE(snippet_id, shared_with) + ) + `); +} + +// ── Link-Liste Ordner + Erweiterungen ───────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_folders'").get()) { + db.exec(` + CREATE TABLE link_list_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📁', + sort_order INTEGER DEFAULT 0, + in_quickaccess INTEGER DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} +// link_list Spalten nachrüsten +{ + const cols = db.pragma('table_info(link_list)').map(c => c.name); + if (!cols.includes('folder_id')) db.exec('ALTER TABLE link_list ADD COLUMN folder_id INTEGER DEFAULT NULL'); + if (!cols.includes('sort_order')) db.exec('ALTER TABLE link_list ADD COLUMN sort_order INTEGER DEFAULT 0'); + if (!cols.includes('in_quickaccess')) db.exec('ALTER TABLE link_list ADD COLUMN in_quickaccess INTEGER DEFAULT 0'); +} + + +// ── QR-Codes ────────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='qr_codes'").get()) { + db.exec(` + CREATE TABLE qr_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 256, + fg_color TEXT NOT NULL DEFAULT '#000000', + bg_color TEXT NOT NULL DEFAULT '#ffffff', + margin INTEGER NOT NULL DEFAULT 4, + dot_style TEXT NOT NULL DEFAULT 'square', + corner_style TEXT NOT NULL DEFAULT 'square', + caption TEXT NOT NULL DEFAULT '', + caption_pos TEXT NOT NULL DEFAULT 'bottom', + caption_color TEXT NOT NULL DEFAULT '#000000', + caption_size INTEGER NOT NULL DEFAULT 14, + created_at DATETIME DEFAULT NULL + ) + `); +} + +// ── Link-Folder Shares ──────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_folder_shares'").get()) { + db.exec(` + CREATE TABLE link_folder_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL REFERENCES link_list_folders(id) ON DELETE CASCADE, + shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT NULL, + UNIQUE(folder_id, shared_with) + ) + `); +} + +// ── Kalender-Event-Cache für Suche ──────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_event_cache'").get()) { + db.exec(` + CREATE TABLE calendar_event_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER NOT NULL REFERENCES calendar_feeds(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + summary TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + start_dt TEXT, + end_dt TEXT, + synced_at DATETIME DEFAULT NULL + ) + `); +} + +// short_url auf upload_shares +{ + const cols = db.pragma('table_info(upload_shares)').map(c => c.name); + if (!cols.includes('short_url')) + db.exec('ALTER TABLE upload_shares ADD COLUMN short_url TEXT DEFAULT NULL'); +} + + +// ── Movie-Favoriten ─────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='movie_favorites'").get()) { + db.exec(` + CREATE TABLE movie_favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tmdb_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + poster_path TEXT NOT NULL DEFAULT '', + release_date_de TEXT NOT NULL DEFAULT '', + added_at DATETIME DEFAULT NULL, + UNIQUE(user_id, tmdb_id) + ) + `); +} + +// movie_favorites neue Spalten (Migration) +{ + const cols = db.pragma('table_info(movie_favorites)').map(c => c.name); + if (!cols.includes('release_date')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''"); + if (!cols.includes('genres')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'"); + if (!cols.includes('fsk')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''"); + if (!cols.includes('acknowledged')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged INTEGER NOT NULL DEFAULT 0"); + if (!cols.includes('acknowledged_at')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged_at DATETIME DEFAULT NULL"); + if (!cols.includes('user_notified')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN user_notified INTEGER NOT NULL DEFAULT 0"); + if (!cols.includes('media_type')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN media_type TEXT NOT NULL DEFAULT 'movie'"); + if (!cols.includes('admin_seen')) + db.exec("ALTER TABLE movie_favorites ADD COLUMN admin_seen INTEGER NOT NULL DEFAULT 0"); +} + +// xREL Badge Cache (persistent, überlebt Container-Restarts) +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xrel_cache'").get()) { + db.exec(` + CREATE TABLE xrel_cache ( + cache_key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL + ) + `); +} + +// TMDb-Token Default +if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()) + db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run(); + +// Öffentliche Datei/Ordner-Freigabe Links +// CREATE TABLE IF NOT EXISTS – sicher bei jedem Start +db.exec(` + CREATE TABLE IF NOT EXISTS public_file_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + label TEXT, + password_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + download_count INTEGER NOT NULL DEFAULT 0 + ) +`); + +// 3D-Druck Ausgaben (Haushaltsbuch) +db.exec(` + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sonstiges', + description TEXT NOT NULL, + amount REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein +// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon +// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock) +db.exec(` + CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein +// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft +db.exec(` + CREATE TABLE IF NOT EXISTS makerworld_stats ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL + ) +`); + +// Log aller verschickten Pushover-Nachrichten (für Admin-Übersicht "Logs") +db.exec(` + CREATE TABLE IF NOT EXISTS push_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + title TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + success INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +// KöPi: Whitelist der exakt gewünschten Filialen (echte marktguru Store-IDs, +// aufgelöst aus bestätigten Prospekt-Links — siehe koepi/routes.js) +db.exec(` + CREATE TABLE IF NOT EXISTS koepi_local_stores ( + store_id INTEGER PRIMARY KEY, + retailer TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + resolved_at DATETIME + ) +`); + +// Besuche öffentlicher, loginfreier Links (KöPi-Teilen-Link, ggf. künftig +// weitere) — für die Admin-Log-Übersicht +db.exec(` + CREATE TABLE IF NOT EXISTS public_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_type TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + created_at DATETIME + ) +`); +{ + const cols = db.pragma('table_info(public_access_log)').map(c => c.name); + if (!cols.includes('device')) + db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''"); + if (!cols.includes('link_name')) + db.exec("ALTER TABLE public_access_log ADD COLUMN link_name TEXT NOT NULL DEFAULT ''"); +} + +// KöPi: benannte, öffentliche Teilen-Links (mehrere gleichzeitig möglich, +// jeweils mit eigenem Namen wie "Sina") statt nur einem einzigen globalen Link +db.exec(` + CREATE TABLE IF NOT EXISTS koepi_share_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + created_at DATETIME + ) +`); +// Migration: den bisherigen einzelnen Link (war für Sina gedacht) einmalig in +// die neue Tabelle übernehmen, statt ihn ungültig zu machen +{ + const oldToken = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_share_token'").get()?.value; + if (oldToken) { + const already = db.prepare('SELECT 1 FROM koepi_share_links WHERE token=?').get(oldToken); + if (!already) { + db.prepare(` + INSERT INTO koepi_share_links (token, name, created_at) + VALUES (?, 'Sina', datetime('now','localtime')) + `).run(oldToken); + } + db.prepare("DELETE FROM admin_settings WHERE key='koepi_share_token'").run(); + } +} + +module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..965c657 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,362 @@ +require('./consoleLog').installConsoleCapture(); + +const express = require('express'); +const path = require('path'); +const fs = require('fs'); + +require('./db'); +const { logPush } = require('./pushLog'); + +const app = express(); +// Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol +// immer "http", auch wenn der Nutzer über https zugreift (z.B. wichtig für +// das MakerWorld-Bookmarklet: sonst mixed-content-blockiert der Browser den +// fetch()-Call stillschweigend, weil die generierte Origin auf http:// zeigt) +app.set('trust proxy', true); +app.use(express.json({ limit: '10mb' })); + +// ── Aktivitäts-Tracking: nur bei echten Aktionen (POST/PUT/DELETE) ───────────── +app.use((req, res, next) => { + if (['POST','PUT','DELETE'].includes(req.method)) { + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ')) { + try { + const jwt = require('jsonwebtoken'); + const p = jwt.verify(auth.slice(7), process.env.JWT_SECRET || 'dev-secret'); + if (p?.id) { + // Prüfen ob User vorher >15 Min inaktiv war → "gerade online gegangen" + const user = db.prepare('SELECT last_active_at, role FROM users WHERE id=?').get(p.id); + const wasInactive = !user?.last_active_at || + (Date.now() - new Date(user.last_active_at).getTime()) > 15 * 60 * 1000; + + db.prepare("UPDATE users SET last_active_at=datetime('now','localtime') WHERE id=?").run(p.id); + + // Pushover an alle Admins (außer wenn User selbst Admin ist) + if (wasInactive && user?.role !== 'admin') { + const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand'; + const admins = db.prepare(` + SELECT p.user_id, p.user_key, p.app_token FROM pushover_settings p + JOIN users u ON u.id = p.user_id + WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL + `).all(); + for (const admin of admins) { + const title = '👤 DickenDock'; + const message = `${username} ist gerade online gegangen.`; + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: admin.app_token, + user: admin.user_key, + title, + message, + priority: -1, + }), + }).catch(() => {}); + logPush({ userId: admin.user_id, title, message, priority: -1, source: 'presence' }); + } + } + } + } catch {} + } + } + next(); +}); + +// ── Security Headers ────────────────────────────────────────────────────────── +app.use((_req, res, next) => { + res.setHeader('Content-Security-Policy', [ + "default-src 'self'", + "script-src 'self'", + // unsafe-inline nötig für React inline-styles + @import Google Fonts + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + // iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig + "connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000", + // data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget + "img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org", + "worker-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-src https://archive.ph https://archive.is https://archive.today", + ].join('; ')); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + next(); +}); + +// ── API ─────────────────────────────────────────────────────────────────────── +app.use('/api/auth', require('./routes/auth')); +app.use('/api/admin', require('./routes/admin')); +app.use('/api/calendar', require('./routes/calendar')); +app.use('/api/dashboard', require('./routes/dashboard')); +app.use('/api/system', require('./routes/system')); + +// Öffentliche Datei-Share Verwaltung (vor Auto-Loader damit /:id nicht matcht) +app.use('/api/tools/dateien/public-shares', require('./tools/dateien/public-share')); + +// Tool-Routen automatisch laden +const toolsDir = path.join(__dirname, 'tools'); +fs.readdirSync(toolsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .forEach(d => { + const routeFile = path.join(toolsDir, d.name, 'routes.js'); + if (fs.existsSync(routeFile)) { + app.use(`/api/tools/${d.name}`, require(routeFile)); + console.log(` 🔧 Tool geladen: ${d.name}`); + } + }); + +app.use('/api/tools/snippets', require('./tools/snippets/routes')); +app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes')); +app.use('/api/upload-shares', require('./tools/dateien/upload-share')); +app.use('/api/public/file-share', require('./tools/dateien/public-share-public')); +app.use('/api/search', require('./routes/search')); + +// Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst +app.get('/u/:token', (_req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache'); + res.sendFile(require('path').join(PUBLIC, 'index.html')); +}); + +// Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst +app.get('/s/:token', (_req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache'); + res.sendFile(require('path').join(PUBLIC, 'index.html')); +}); + +const PUBLIC = path.join(__dirname, '../public'); + +app.get('/manifest.json', (_req, res) => { + res.setHeader('Content-Type', 'application/manifest+json'); + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile(path.join(PUBLIC, 'manifest.json')); +}); +app.get('/sw.js', (_req, res) => { + res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Service-Worker-Allowed', '/'); + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile(path.join(PUBLIC, 'sw.js')); +}); + +app.use(express.static(PUBLIC, { + setHeaders: (res, filePath) => { + // JS/CSS Assets haben Hash im Namen → lang cachen + if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + } +})); + +// index.html nie cachen +app.get('*', (_req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.sendFile(path.join(PUBLIC, 'index.html')); +}); + +// Build-Zeit Endpoint – Frontend prüft ob es eine neue Version gibt +app.get('/api/build-time', (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); + try { + const ver = require('fs').readFileSync(path.join(__dirname, '../version.txt'), 'utf8').trim(); + res.json({ buildTime: ver }); + } catch { res.json({ buildTime: 'unknown' }); } +}); + +// ── HTTP + WebSocket Server ─────────────────────────────────────────────────── +const http = require('http'); +const { WebSocketServer } = require('ws'); +const jwt = require('jsonwebtoken'); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws/whiteboard' }); + +// Raum-Map: whiteboard_id → Set +const rooms = new Map(); + +wss.on('connection', (ws, req) => { + // Token aus Query-String auslesen: /ws/whiteboard?token=...&id=... + const params = new URL(req.url, 'http://localhost').searchParams; + const token = params.get('token'); + const wbId = parseInt(params.get('id')); + + let userId = null; + try { + const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret'); + userId = p.id; + } catch { + ws.close(1008, 'Unauthorized'); + return; + } + + // Zugriff prüfen + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId); + if (!wb) { ws.close(1008, 'Not found'); return; } + const isOwner = wb.owner_id === userId; + const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId); + if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; } + const role = isOwner ? 'owner' : perm.role; + + ws.userId = userId; + ws.wbId = wbId; + ws.role = role; + ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt'; + + // Raum beitreten + if (!rooms.has(wbId)) rooms.set(wbId, new Set()); + rooms.get(wbId).add(ws); + + // Anderen im Raum mitteilen wer jointe + broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws); + + // Aktive User-Liste an neuen Client schicken + const activeUsers = [...rooms.get(wbId)] + .filter(c => c !== ws && c.readyState === 1) + .map(c => ({ userId: c.userId, username: c.username, role: c.role })); + ws.send(JSON.stringify({ type: 'active_users', users: activeUsers })); + + ws.on('message', raw => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + + switch (msg.type) { + case 'cursor': + // Cursor-Position live broadcasten (nur wenn nicht view-only) + broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws); + break; + + case 'elements': + // Canvas-Änderungen von Edit-Berechtigten an alle broadcasten + if (role === 'view') break; + broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws); + break; + + case 'ping': + ws.send(JSON.stringify({ type: 'pong' })); + break; + } + }); + + ws.on('close', () => { + const room = rooms.get(wbId); + if (room) { + room.delete(ws); + if (room.size === 0) rooms.delete(wbId); + else broadcast(wbId, { type: 'user_leave', userId, username: ws.username }); + } + }); + + ws.on('error', () => ws.terminate()); +}); + +function broadcast(wbId, msg, exclude = null) { + const room = rooms.get(wbId); + if (!room) return; + const data = JSON.stringify(msg); + for (const client of room) { + if (client !== exclude && client.readyState === 1) { + client.send(data); + } + } +} + +server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000')); + +// ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── +const db = require('./db'); +const koepiRoutes = require('./tools/koepi/routes'); +const mediaRoutes = require('./tools/media/routes'); +const mqttClient = require('./mqtt'); + +mqttClient.connectMqtt(); +// KöPi: Filial-Whitelist bei jedem Serverstart automatisch neu auflösen +// (nicht erst nach der 6-Tage-Frist) — läuft asynchron im Hintergrund, damit +// ein Deploy nicht auf den marktguru-Abruf warten muss +koepiRoutes.resolveLocalStores() + .then(n => console.log(`🍺 KöPi: Filial-Whitelist beim Start aufgelöst (${n} Filiale(n))`)) + .catch(e => console.error('🍺 KöPi: Filial-Whitelist-Auflösung beim Start fehlgeschlagen:', e.message)); +// HA-Button "KöPi Check jetzt" ist ein reiner Anzeige-Refresh — sendet nie +// Pushover. Benachrichtigungen bleiben exklusiv dem 06:00-Cron vorbehalten. +mqttClient.setKoepiCheckHandler(() => koepiRoutes.refreshOffersOnly()); +// HA-Buttons "Media Quittieren" / "Media Alle quittieren" — danach sofort den +// Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren +mqttClient.setMediaAckHandler((id) => { + mediaRoutes.ackFavorite(id); + mqttClient.publishMediaAnfragen(); +}); +mqttClient.setMediaAckAllHandler(() => { + mediaRoutes.ackAllFavorites(); + mqttClient.publishMediaAnfragen(); +}); + +async function sendPushoverMsg(userKey, appToken, message, opts = {}, userId = null) { + const title = 'DickenDock Erinnerung'; + let priority = 0; + try { + const params = { token: appToken, user: userKey, title, message }; + if (opts.retry && opts.expire) { + params.priority = 2; + priority = 2; + params.retry = opts.retry; + params.expire = opts.expire; + } + await fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + logPush({ userId, title, message, priority, source: 'dashboard-erinnerung' }); + } catch(e) { + console.error('Pushover Fehler:', e.message); + logPush({ userId, title, message, priority, source: 'dashboard-erinnerung', success: false }); + } +} + +// Beim Start auf die nächste volle Minute synchronisieren +const msToNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds(); +setTimeout(() => { + runScheduler(); + setInterval(runScheduler, 60 * 1000); +}, msToNextMinute); + +async function runScheduler() { + mqttClient.publishPresence(); + mqttClient.publishMediaAnfragen(); + mqttClient.publishDruckStatistik(); + mqttClient.publishPushErinnerungen(); + mqttClient.publishKanban(); + try { + const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; + const due = db.prepare(` + SELECT s.*, p.user_key, p.app_token, p.retry, p.expire + FROM push_schedules s + JOIN pushover_settings p ON p.user_id = s.user_id + WHERE s.sent = 0 AND s.scheduled_at <= ? + `).all(now); + for (const row of due) { + await sendPushoverMsg(row.user_key, row.app_token, row.message, + { retry: row.retry, expire: row.expire }, row.user_id); + db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id); + console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`); + } + } catch(e) { console.error('Push-Job Fehler:', e.message); } + + // KöPi Tages-Check: täglich um 06:00 (localtime), Duplikatschutz über admin_settings-Datum + try { + const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; + const date = now.slice(0, 10); + const time = now.slice(11, 16); + if (time === '06:00') { + const lastRunDate = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_cron_last_date'").get()?.value; + if (lastRunDate !== date) { + db.prepare("INSERT OR REPLACE INTO admin_settings (key, value) VALUES ('koepi_cron_last_date', ?)").run(date); + await koepiRoutes.runDailyCheck(); + } + } + } catch(e) { console.error('KöPi-Cron Fehler:', e.message); } +} + +console.log(`⏰ Push-Scheduler aktiv – nächste Prüfung in ${Math.round(msToNextMinute/1000)}s`); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..ae29f2e --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,16 @@ +const jwt = require('jsonwebtoken'); +const SECRET = process.env.JWT_SECRET || 'dev-secret'; + +function authenticate(req, res, next) { + const h = req.headers.authorization; + if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Nicht eingeloggt' }); + try { req.user = jwt.verify(h.slice(7), SECRET); next(); } + catch { res.status(401).json({ error: 'Token ungültig' }); } +} + +function requireAdmin(req, res, next) { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + next(); +} + +module.exports = { authenticate, requireAdmin }; diff --git a/backend/src/mqtt.js b/backend/src/mqtt.js new file mode 100644 index 0000000..2a4320a --- /dev/null +++ b/backend/src/mqtt.js @@ -0,0 +1,620 @@ +// ── Home Assistant MQTT Integration ────────────────────────────────────────── +// Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten +// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten +// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT". +// +// Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KöPi", "Media", +// "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert +// untereinander stehen. +// +// Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt +// der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops). + +const mqtt = require('mqtt'); +const db = require('./db'); + +const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock'; +const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`; +const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`; +const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`; +const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set +const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`; + +const DEVICE = { + identifiers: ['dickendock'], + name: 'DickenDock', + manufacturer: 'Flo', + model: 'DockStation', +}; + +let client = null; +let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden +let mediaAckHandler = null; // (id) => void — einzelnen Favoriten quittieren +let mediaAckAllHandler = null; // () => void — alle Favoriten quittieren + +// Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten +// wenn ein Favorit quittiert/gelöscht wurde und der Button verschwinden soll) +let publishedMediaAckIds = new Set(); + +function connectMqtt() { + if (!process.env.MQTT_HOST) { + console.log('MQTT: MQTT_HOST nicht gesetzt – Home-Assistant-Integration deaktiviert.'); + return; + } + + client = mqtt.connect({ + host: process.env.MQTT_HOST, + port: parseInt(process.env.MQTT_PORT || '1883', 10), + username: process.env.MQTT_USERNAME, + password: process.env.MQTT_PASSWORD, + will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true }, + reconnectPeriod: 5000, + }); + + client.on('connect', () => { + console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`); + client.publish(AVAILABILITY_TOPIC, 'online', { retain: true }); + publishDiscovery(); + client.subscribe(KOEPI_CHECK_CMD_TOPIC); + client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC); + client.subscribe(MEDIA_ACK_SUBSCRIBE); + }); + + client.on('message', async (topic) => { + try { + if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { + await koepiCheckHandler(); + } else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') { + await mediaAckAllHandler(); + } else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') { + const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, ''); + if (id) await mediaAckHandler(id); + } + } catch (e) { + console.error('MQTT Command-Fehler:', e.message); + } + }); + + client.on('error', (e) => console.error('MQTT Fehler:', e.message)); +} + +function publishDiscovery() { + if (!client) return; + const entities = [ + // ── KöPi ────────────────────────────────────────────────────────────── + ['sensor', 'dickendock_koepi_angebot', { + name: 'KöPi Angebote', + unique_id: 'dickendock_koepi_bestes_angebot', + state_topic: `${BASE_TOPIC}/koepi/state`, + json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`, + icon: 'mdi:beer', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['binary_sensor', 'dickendock_koepi_changed', { + name: 'KöPi Angebot geändert', + unique_id: 'dickendock_koepi_changed', + state_topic: `${BASE_TOPIC}/koepi/changed`, + payload_on: 'ON', + payload_off: 'OFF', + icon: 'mdi:bell-alert', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['button', 'dickendock_koepi_check', { + name: 'KöPi Check jetzt', + unique_id: 'dickendock_koepi_check', + command_topic: KOEPI_CHECK_CMD_TOPIC, + icon: 'mdi:refresh', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['sensor', 'dickendock_koepi_start', { + name: 'KöPi Angebot Start', + unique_id: 'dickendock_koepi_start', + state_topic: `${BASE_TOPIC}/koepi/start`, + device_class: 'date', + icon: 'mdi:calendar-start', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['sensor', 'dickendock_koepi_ende', { + name: 'KöPi Angebot Ende', + unique_id: 'dickendock_koepi_ende', + state_topic: `${BASE_TOPIC}/koepi/ende`, + device_class: 'date', + icon: 'mdi:calendar-end', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + + // ── Media ───────────────────────────────────────────────────────────── + ['sensor', 'dickendock_media_anfragen', { + name: 'Media Anfragen offen', + unique_id: 'dickendock_media_anfragen', + state_topic: `${BASE_TOPIC}/media/anfragen`, + json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`, + icon: 'mdi:movie-open-star', + unit_of_measurement: 'Anfragen', + state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['button', 'dickendock_media_ack_all', { + name: 'Media Alle quittieren', + unique_id: 'dickendock_media_ack_all', + command_topic: MEDIA_ACK_ALL_CMD_TOPIC, + icon: 'mdi:check-all', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + + // ── 3D ──────────────────────────────────────────────────────────────── + ['sensor', 'dickendock_3d_umsatz_monat', { + name: '3D Umsatz Monat', + unique_id: 'dickendock_3ddruck_umsatz_monat', + state_topic: `${BASE_TOPIC}/druck/umsatz_monat`, + icon: 'mdi:cash-multiple', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_umsatz_gesamt', { + name: '3D Umsatz Gesamt', + unique_id: 'dickendock_3d_umsatz_gesamt', + state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`, + icon: 'mdi:cash-multiple', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_grundkosten_monat', { + name: '3D Grundkosten Monat', + unique_id: 'dickendock_3d_grundkosten_monat', + state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`, + icon: 'mdi:currency-eur-off', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_grundkosten_gesamt', { + name: '3D Grundkosten Gesamt', + unique_id: 'dickendock_3d_grundkosten_gesamt', + state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`, + icon: 'mdi:currency-eur-off', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_rohgewinn_monat', { + name: '3D Rohgewinn Monat', + unique_id: 'dickendock_3d_rohgewinn_monat', + state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`, + icon: 'mdi:chart-line', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_rohgewinn_gesamt', { + name: '3D Rohgewinn Gesamt', + unique_id: 'dickendock_3d_rohgewinn_gesamt', + state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`, + icon: 'mdi:chart-line', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_ausgaben_monat', { + name: '3D Ausgaben Monat', + unique_id: 'dickendock_3d_ausgaben_monat', + state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`, + icon: 'mdi:receipt-text-minus', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_ausgaben_gesamt', { + name: '3D Ausgaben Gesamt', + unique_id: 'dickendock_3d_ausgaben_gesamt', + state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`, + icon: 'mdi:receipt-text-minus', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_nettogewinn_monat', { + name: '3D Nettogewinn Monat', + unique_id: 'dickendock_3d_nettogewinn_monat', + state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`, + icon: 'mdi:cash-check', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_nettogewinn_gesamt', { + name: '3D Nettogewinn Gesamt', + unique_id: 'dickendock_3d_nettogewinn_gesamt', + state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`, + icon: 'mdi:cash-check', + device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_offen', { + name: '3D Bestellungen offen', + unique_id: 'dickendock_bestellungen_offen', + state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`, + json_attributes_topic: `${BASE_TOPIC}/druck/bestellungen_offen_attributes`, + icon: 'mdi:cube-send', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_warteliste', { + name: '3D Bestellungen Warteliste', + unique_id: 'dickendock_3d_bestellungen_warteliste', + state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`, + icon: 'mdi:timer-sand', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_in_arbeit', { + name: '3D Bestellungen In Arbeit', + unique_id: 'dickendock_3d_bestellungen_in_arbeit', + state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`, + icon: 'mdi:printer-3d-nozzle', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_fertig', { + name: '3D Bestellungen Fertig', + unique_id: 'dickendock_3d_bestellungen_fertig', + state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`, + icon: 'mdi:check-circle-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_bezahlt', { + name: '3D Bestellungen Bezahlt', + unique_id: 'dickendock_3d_bestellungen_bezahlt', + state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`, + icon: 'mdi:cash-check', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_abgeschlossen', { + name: '3D Bestellungen Abgeschlossen', + unique_id: 'dickendock_3d_bestellungen_abgeschlossen', + state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`, + icon: 'mdi:archive-check-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + ['sensor', 'dickendock_3d_bestellungen_gesamt', { + name: '3D Bestellungen Gesamt', + unique_id: 'dickendock_3d_bestellungen_gesamt', + state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`, + icon: 'mdi:cube-outline', + unit_of_measurement: 'Bestellungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + + // ── Dashboard ───────────────────────────────────────────────────────── + ['sensor', 'dickendock_dashboard_push_erinnerungen', { + name: 'Dashboard Push-Erinnerungen offen', + unique_id: 'dickendock_dashboard_push_erinnerungen', + state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`, + json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, + icon: 'mdi:bell-ring-outline', + unit_of_measurement: 'Erinnerungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + + // ── Kanban ──────────────────────────────────────────────────────────── + ['sensor', 'dickendock_kanban_karten', { + name: 'Kanban Karten offen', + unique_id: 'dickendock_kanban_karten', + state_topic: `${BASE_TOPIC}/kanban/karten`, + json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`, + icon: 'mdi:view-column-outline', + unit_of_measurement: 'Karten', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + + // ── System ──────────────────────────────────────────────────────────── + ['sensor', 'dickendock_system_online', { + name: 'System Nutzer online', + unique_id: 'dickendock_nutzer_online', + state_topic: `${BASE_TOPIC}/presence/state`, + json_attributes_topic: `${BASE_TOPIC}/presence/attributes`, + icon: 'mdi:account-multiple', + unit_of_measurement: 'Nutzer', + state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ]; + for (const [component, objectId, payload] of entities) { + client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true }); + } +} + +function round2(n) { return Math.round((n || 0) * 100) / 100; } + +function parsePrice(str) { + if (!str) return Infinity; + const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.')); + return Number.isNaN(n) ? Infinity : n; +} + +// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel. +// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur +// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen). +function parseGermanDate(str, ref = new Date()) { + const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/); + if (!m) return null; + const day = parseInt(m[1], 10); + const month = parseInt(m[2], 10) - 1; + let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear(); + const d = new Date(year, month, day); + if (!m[3]) { + const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth()); + if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint + else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint + } + return d; +} + +// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin +// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum) +function extractDateRange(offers) { + let start = null, end = null; + for (const o of offers) { + const text = o.dateRange || ''; + const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]); + if (found.length >= 2) { + const d1 = parseGermanDate(found[0]); + const d2 = parseGermanDate(found[1]); + if (d1 && (!start || d1 < start)) start = d1; + if (d2 && (!end || d2 > end)) end = d2; + } else if (found.length === 1) { + const d1 = parseGermanDate(found[0]); + if (d1 && (!end || d1 > end)) end = d1; + } + } + return { start, end }; +} + +function toIsoDate(d) { + if (!d) return null; + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen +function publishKoepiState({ offers, changed }) { + if (!client?.connected) return; + const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price)); + + // State: nur die Anzahl (bleibt immer kurz und stabil) — die eigentlichen + // Angebote stehen vollständig in den Attributen + const state = list.length ? `${list.length} Angebote` : 'keine Angebote'; + + // Lesbarer Mehrzeiler fürs Dashboard, zusätzlich zur strukturierten Liste + const listeText = list.length + ? list.map(o => `${o.retailer}: ${o.price}${o.dateRange ? ' (' + o.dateRange + ')' : ''}`).join('\n') + : 'keine Angebote'; + + client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true }); + client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({ + angebote: list, + liste_text: listeText, + anzahl: list.length, + letzter_check: new Date().toISOString(), + }), { retain: true }); + client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true }); + + const { start, end } = extractDateRange(list); + client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true }); + client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true }); +} + +// Wird jede Minute vom Scheduler in index.js aufgerufen +function publishPresence() { + if (!client?.connected) return; + const alleNutzer = db.prepare(` + SELECT username, last_active_at, + CASE WHEN last_active_at > datetime('now','localtime','-5 minutes') THEN 1 ELSE 0 END AS online + FROM users + ORDER BY last_active_at DESC + `).all(); + const online = alleNutzer.filter(u => !!u.online); + + client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true }); + client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({ + online: online.map(u => ({ benutzer: u.username, zuletzt_aktiv: u.last_active_at })), + alle_nutzer: alleNutzer.map(u => ({ + benutzer: u.username, + zuletzt_aktiv: u.last_active_at, + online: !!u.online, + })), + }), { retain: true }); +} + +// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen. +// Offene (unquittierte) Media-Favoriten, systemweit über alle Nutzer. +// Legt zusätzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an +// und entfernt Buttons für Anfragen, die inzwischen quittiert/gelöscht wurden. +function publishMediaAnfragen() { + if (!client?.connected) return; + const open = db.prepare(` + SELECT f.id, f.title, f.added_at, u.username + FROM movie_favorites f JOIN users u ON u.id = f.user_id + WHERE f.acknowledged = 0 + ORDER BY f.added_at DESC + `).all(); + + client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true }); + client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true }); + + const currentIds = new Set(open.map(f => String(f.id))); + + // Neue Buttons für neu hinzugekommene offene Anfragen anlegen + for (const fav of open) { + const id = String(fav.id); + if (!publishedMediaAckIds.has(id)) { + const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255); + client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({ + name: label, + unique_id: `dickendock_media_ack_${id}`, + command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`, + icon: 'mdi:check-circle-outline', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }), { retain: true }); + } + } + // Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload) + for (const id of publishedMediaAckIds) { + if (!currentIds.has(id)) { + client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true }); + } + } + publishedMediaAckIds = currentIds; +} + +// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der +// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js) +function getOrderRevenue(order) { + if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price); + const r = db.prepare(` + SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s + FROM order_items WHERE order_id=? AND custom_price IS NOT NULL + `).get(order.id); + return r?.s || 0; +} + +// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js +function getOrderBaseCost(orderId) { + const r = db.prepare(` + SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s + FROM order_items oi + LEFT JOIN calculations c ON c.id = oi.calculation_id + WHERE oi.order_id = ? + `).get(orderId); + return r?.s || 0; +} + +// Wird jede Minute vom Scheduler aufgerufen — umfangreiche 3D-Druck-Statistik, +// systemweit über alle Nutzer, jeweils für den laufenden Monat und komplett (all-time) +function publishDruckStatistik() { + if (!client?.connected) return; + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`; + const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + + const allOrders = db.prepare('SELECT * FROM orders').all(); + const paidAll = allOrders.filter(o => !!o.bezahlt); + const paidMonat = paidAll.filter(o => { + const d = (o.bezahlt_am || o.created_at || '').slice(0, 10); + return d >= monatsAnfang && d <= heute; + }); + + const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0); + const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0); + const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0); + const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0); + const rohgewinnGesamt = umsatzGesamt - kostenGesamt; + const rohgewinnMonat = umsatzMonat - kostenMonat; + + const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s; + const ausgabenMonat = db.prepare( + 'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?' + ).get(monatsAnfang, heute).s; + + const nettoGesamt = rohgewinnGesamt - ausgabenGesamt; + const nettoMonat = rohgewinnMonat - ausgabenMonat; + + const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 }; + for (const o of allOrders) { + if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++; + else if (o.bezahlt) byStatus.bezahlt++; + else if (o.status in byStatus) byStatus[o.status]++; + } + const offen = allOrders.length - byStatus.abgeschlossen; + + // Konkrete offene Bestellungen (nicht abgeschlossen) mit Besitzer, für die Attribute + const offeneBestellungen = db.prepare(` + SELECT o.id, o.name, o.status, o.bezahlt, o.abgeholt, o.created_at, u.username + FROM orders o JOIN users u ON u.id = o.user_id + WHERE NOT (o.bezahlt = 1 AND o.abgeholt = 1) + ORDER BY o.created_at DESC + `).all(); + + const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true }); + pub('druck/umsatz_monat', round2(umsatzMonat)); + pub('druck/umsatz_gesamt', round2(umsatzGesamt)); + pub('druck/grundkosten_monat', round2(kostenMonat)); + pub('druck/grundkosten_gesamt', round2(kostenGesamt)); + pub('druck/rohgewinn_monat', round2(rohgewinnMonat)); + pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt)); + pub('druck/ausgaben_monat', round2(ausgabenMonat)); + pub('druck/ausgaben_gesamt', round2(ausgabenGesamt)); + pub('druck/nettogewinn_monat', round2(nettoMonat)); + pub('druck/nettogewinn_gesamt', round2(nettoGesamt)); + pub('druck/bestellungen_offen', offen); + client.publish(`${BASE_TOPIC}/druck/bestellungen_offen_attributes`, JSON.stringify({ + bestellungen: offeneBestellungen, + }), { retain: true }); + pub('druck/bestellungen_warteliste', byStatus.warteliste); + pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit); + pub('druck/bestellungen_fertig', byStatus.fertig); + pub('druck/bestellungen_bezahlt', byStatus.bezahlt); + pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen); + pub('druck/bestellungen_gesamt', allOrders.length); +} + +// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete, +// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit +function publishPushErinnerungen() { + if (!client?.connected) return; + const offen = db.prepare(` + SELECT s.message, s.scheduled_at, u.username + FROM push_schedules s JOIN users u ON u.id = s.user_id + WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime') + ORDER BY s.scheduled_at ASC + `).all(); + client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true }); + client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({ + erinnerungen: offen, + }), { retain: true }); +} + +// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle +// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert) +function publishKanban() { + if (!client?.connected) return; + const karten = db.prepare(` + SELECT k.title, k.priority, col.title AS spalte, u.username + FROM kanban_cards k + JOIN kanban_columns col ON col.id = k.column_id + JOIN users u ON u.id = k.user_id + ORDER BY u.username ASC, col.position ASC, k.position ASC + `).all(); + const proNutzer = {}; + for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1; + + client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true }); + client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({ + karten, + pro_nutzer: proNutzer, + }), { retain: true }); +} + +// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden +function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } +function setMediaAckHandler(fn) { mediaAckHandler = fn; } +function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; } + +module.exports = { + connectMqtt, + publishKoepiState, + publishPresence, + publishMediaAnfragen, + publishDruckStatistik, + publishPushErinnerungen, + publishKanban, + setKoepiCheckHandler, + setMediaAckHandler, + setMediaAckAllHandler, +}; diff --git a/backend/src/publicAccessLog.js b/backend/src/publicAccessLog.js new file mode 100644 index 0000000..e57d775 --- /dev/null +++ b/backend/src/publicAccessLog.js @@ -0,0 +1,85 @@ +const db = require('./db'); + +// Sehr einfache, kostenlose IP-Geolocation ohne API-Key (ip-api.com, ~45 +// Anfragen/Minute im Free-Tier — für dieses Nutzungsszenario völlig ausreichend). +// Liefert bei privaten/lokalen IPs oder Fehlern bewusst leeren String zurück, +// statt den eigentlichen Log-Eintrag zu blockieren. +async function lookupLocation(ip) { + if (!ip) return ''; + const clean = ip.replace('::ffff:', ''); // IPv4-mapped IPv6-Adressen bereinigen + if (!clean || clean === '::1' || clean.startsWith('127.') || clean.startsWith('192.168.') || clean.startsWith('10.')) { + return ''; // lokale/private Adresse, Geolocation ergibt keinen Sinn + } + try { + const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(clean)}?fields=status,country,city`); + if (!res.ok) return ''; + const data = await res.json(); + if (data.status !== 'success') return ''; + return [data.city, data.country].filter(Boolean).join(', '); + } catch { + return ''; + } +} + +// Sehr einfache User-Agent-Erkennung (kein externes Paket nötig) — liefert +// z.B. "iPhone · Safari" oder "Windows · Firefox". Reihenfolge ist wichtig: +// Edge/Samsung/Opera/Firefox enthalten selbst oft "Chrome" bzw. "Safari" im +// User-Agent, müssen also VOR diesen geprüft werden. +function parseDevice(ua) { + if (!ua) return ''; + let os = ''; + if (/iPhone/i.test(ua)) os = 'iPhone'; + else if (/iPad/i.test(ua)) os = 'iPad'; + else if (/Android/i.test(ua)) os = 'Android'; + else if (/Windows/i.test(ua)) os = 'Windows'; + else if (/Macintosh|Mac OS X/i.test(ua)) os = 'Mac'; + else if (/Linux/i.test(ua)) os = 'Linux'; + + let browser = ''; + if (/EdgA|Edge|Edg\//i.test(ua)) browser = 'Edge'; + else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet'; + else if (/OPR\/|Opera/i.test(ua)) browser = 'Opera'; + else if (/Firefox/i.test(ua)) browser = 'Firefox'; + else if (/CriOS/i.test(ua)) browser = 'Chrome'; // Chrome auf iOS + else if (/Chrome/i.test(ua)) browser = 'Chrome'; + else if (/Safari/i.test(ua)) browser = 'Safari'; + + return [os, browser].filter(Boolean).join(' · '); +} + +// Wird von öffentlichen (loginfreien) Routen aufgerufen, sobald sie besucht +// werden. linkType z.B. 'koepi_share'. Läuft bewusst asynchron/"fire and +// forget" im Hintergrund — ein Fehler hier darf niemals die eigentliche +// öffentliche Anfrage blockieren oder verlangsamen. +// +// Dedup: dieselbe IP, die denselben Link innerhalb der letzten 5 Minuten schon +// besucht hat, wird nicht erneut geloggt (verhindert Log-Spam bei mehreren +// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige +// Geolocation-Abfragen). +const DEDUP_WINDOW_MINUTES = 5; +function logPublicAccess({ linkType, path, ip, userAgent, linkName }) { + try { + const recent = db.prepare(` + SELECT 1 FROM public_access_log + WHERE ip = ? AND path = ? AND created_at >= datetime('now','localtime',?) + LIMIT 1 + `).get(ip || '', path || '', `-${DEDUP_WINDOW_MINUTES} minutes`); + if (recent) return; // kürzlich schon geloggt, nichts weiter tun + } catch (e) { + console.error('Public-Access-Log Dedup-Fehler:', e.message); + } + + const device = parseDevice(userAgent); + lookupLocation(ip).then(location => { + try { + db.prepare(` + INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime')) + `).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || ''); + } catch (e) { + console.error('Public-Access-Log Fehler:', e.message); + } + }).catch(() => {}); +} + +module.exports = { logPublicAccess }; diff --git a/backend/src/pushLog.js b/backend/src/pushLog.js new file mode 100644 index 0000000..8d25dfb --- /dev/null +++ b/backend/src/pushLog.js @@ -0,0 +1,17 @@ +const db = require('./db'); + +// Wird von jeder Stelle im Code aufgerufen, die eine Pushover-Nachricht +// verschickt — unabhängig vom Versand selbst, damit ein Fehler im Logging +// nie den eigentlichen Push verhindert. +function logPush({ userId = null, title = '', message = '', priority = 0, source = '', success = true }) { + try { + db.prepare(` + INSERT INTO push_log (user_id, title, message, priority, source, success, created_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime')) + `).run(userId, title, message, priority ?? 0, source, success ? 1 : 0); + } catch (e) { + console.error('Push-Log Fehler:', e.message); + } +} + +module.exports = { logPush }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..83e0b0c --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,604 @@ +const express = require('express'); +const fs = require('fs'); +const bcrypt = require('bcryptjs'); +const multer = require('multer'); +const db = require('../db'); +const { logPush } = require('../pushLog'); +const { authenticate, requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); +const DB_PATH = process.env.DB_PATH || '/data/dickendock.db'; +const upload = multer({ dest: '/tmp/' }); + +// GET /api/admin/backup +router.get('/backup', authenticate, requireAdmin, (req, res) => { + if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'DB nicht gefunden' }); + const date = new Date().toISOString().split('T')[0]; + res.download(DB_PATH, `dickendock-backup-${date}.db`); +}); + +// POST /api/admin/restore +router.post('/restore', authenticate, requireAdmin, upload.single('database'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten' }); + try { + if (fs.existsSync(DB_PATH)) fs.copyFileSync(DB_PATH, DB_PATH + '.bak'); + fs.copyFileSync(req.file.path, DB_PATH); + fs.unlinkSync(req.file.path); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/admin/users – alle Benutzer mit Statistiken +router.get('/users', authenticate, requireAdmin, (req, res) => { + const users = db.prepare(` + SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools, + CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover + FROM users u + LEFT JOIN pushover_settings p ON p.user_id = u.id + ORDER BY u.role DESC, u.username + `).all(); + const stats = users.map(u => { + const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c; + const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c; + const snippets_c = db.prepare('SELECT COUNT(*) c FROM snippets WHERE user_id=?').get(u.id).c; + const todos = db.prepare('SELECT COUNT(*) c FROM todos WHERE user_id=?').get(u.id).c; + const links = db.prepare('SELECT COUNT(*) c FROM quick_links WHERE user_id=?').get(u.id).c; + const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c; + const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length; + const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c; + let hidden_tools = []; + try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {} + return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen }; + }); + res.json(stats); +}); + +// POST /api/admin/users – neuen Benutzer anlegen +router.post('/users', authenticate, requireAdmin, (req, res) => { + const { username, password, role = 'user' } = req.body; + if (!username?.trim() || username.trim().length < 3) + return res.status(400).json({ error: 'Benutzername mind. 3 Zeichen' }); + if (!password || password.length < 8) + return res.status(400).json({ error: 'Passwort mind. 8 Zeichen' }); + if (!['user','admin'].includes(role)) + return res.status(400).json({ error: 'Ungültige Rolle' }); + const exists = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim()); + if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' }); + const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)') + .run(username.trim(), bcrypt.hashSync(password, 12), role); + res.json({ id: r.lastInsertRowid, username: username.trim(), role }); +}); + +// DELETE /api/admin/users/:id – Benutzer löschen +router.delete('/users/:id', authenticate, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); + const user = db.prepare('SELECT * FROM users WHERE id=?').get(id); + if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + // Sicherstellen dass mindestens ein Admin bleibt + if (user.role === 'admin') { + const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c; + if (adminCount <= 1) return res.status(400).json({ error: 'Mindestens ein Administrator muss verbleiben' }); + } + // Cascade: alle Daten des Users löschen + db.prepare('DELETE FROM calculations WHERE user_id=?').run(id); + db.prepare('DELETE FROM orders WHERE user_id=?').run(id); + db.prepare('DELETE FROM todos WHERE user_id=?').run(id); + db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id); + db.prepare('DELETE FROM notes WHERE user_id=?').run(id); + db.prepare('DELETE FROM users WHERE id=?').run(id); + res.json({ success: true }); +}); + +// PUT /api/admin/users/:id/reset-password – Passwort zurücksetzen +router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) => { + const { newPassword } = req.body; + if (!newPassword || newPassword.length < 8) + return res.status(400).json({ error: 'Mind. 8 Zeichen' }); + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(newPassword, 12), req.params.id); + res.json({ success: true }); +}); + +// ── Login-Sicherheit Admin-Endpoints ───────────────────────────────────────── + +const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value; + +// GET gesperrte Konten mit IPs +router.get('/lockouts', authenticate, requireAdmin, (req, res) => { + const locked = db.prepare(` + SELECT id, username, failed_attempts, locked_until, last_failed_at + FROM users + WHERE locked_until IS NOT NULL + ORDER BY locked_until DESC + `).all(); + + // IPs aus login_attempts hinzufügen + const result = locked.map(u => { + const ips = db.prepare(` + SELECT DISTINCT ip FROM login_attempts + WHERE username=? AND success=0 + ORDER BY created_at DESC LIMIT 10 + `).all(u.username).map(r => r.ip).filter(Boolean); + return { ...u, ips }; + }); + res.json(result); +}); + +// DELETE Sperre aufheben + Login-Versuche löschen +router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => { + const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.params.userId); + if (user) { + db.prepare('DELETE FROM login_attempts WHERE username=?').run(user.username); + } + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?') + .run(req.params.userId); + res.json({ ok: true }); +}); + +// GET Login-Einstellungen +router.get('/security-settings', authenticate, requireAdmin, (req, res) => { + res.json({ + login_max_attempts: getSetting('login_max_attempts') || '5', + login_lockout_minutes: getSetting('login_lockout_minutes') || '30', + }); +}); + +// PUT Login-Einstellungen +router.put('/security-settings', authenticate, requireAdmin, (req, res) => { + const { login_max_attempts, login_lockout_minutes } = req.body; + for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) { + if (v !== undefined) + db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0)); + } + res.json({ ok: true }); +}); + +// GET Login-Verlauf (letzte 50) +router.get('/login-log', authenticate, requireAdmin, (req, res) => { + res.json(db.prepare(` + SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50 + `).all()); +}); + +// Toggle hidden +router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); + const newHidden = user.hidden ? 0 : 1; + db.prepare('UPDATE users SET hidden=? WHERE id=?').run(newHidden, user.id); + res.json({ hidden: newHidden }); +}); + +// PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen +// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js) +router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); + const { hidden_tools } = req.body; + if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' }); + const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))]; + db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id); + res.json({ hidden_tools: cleaned }); +}); + +// Test-Pushover an beliebigen User senden +router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id); + if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' }); + const { message = 'Wat is mit meinen Fische?' } = req.body; + const title = '📣 DockStation'; + try { + const r = await fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title, message, priority: 0 }), + }); + const d = await r.json(); + if (d.status === 1) { + logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test' }); + res.json({ ok: true }); + } else { + logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false }); + res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' }); + } + } catch(e) { + logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false }); + res.status(500).json({ error: e.message }); + } +}); + +// ── WebDAV / Synology NAS Einstellungen ─────────────────────────────────── +router.get('/webdav', authenticate, requireAdmin, (req, res) => { + const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || ''; + res.json({ + url: get('webdav_url'), + user: get('webdav_user'), + password: get('webdav_password'), + basePath: get('webdav_base_path') || '/dickendock', + enabled: get('webdav_enabled') === '1', + }); +}); + +router.put('/webdav', authenticate, requireAdmin, (req, res) => { + const { url, user, password, basePath, enabled } = req.body; + const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || ''); + set('webdav_url', url); + set('webdav_user', user); + set('webdav_password', password); + set('webdav_base_path', basePath || '/dickendock'); + set('webdav_enabled', enabled ? '1' : '0'); + res.json({ ok: true }); +}); + +router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => { + // Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert) + const { url, user, password, basePath } = req.body; + try { + const https = require('https'); + const http = require('http'); + const base = new URL(url); + const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/'); + const mod = base.protocol === 'https:' ? https : http; + const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64'); + + await new Promise((resolve, reject) => { + const req2 = mod.request({ + hostname: base.hostname, + port: base.port || (base.protocol === 'https:' ? 443 : 80), + path: testPath, + method: 'PROPFIND', + headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' }, + rejectUnauthorized: false, + timeout: 8000, + }, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); }); + req2.on('error', reject); + req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); }); + req2.write(''); + req2.end(); + }); + + res.json({ ok: true }); + } catch(e) { + res.status(502).json({ error: e.message }); + } +}); + +// ── WebDAV Sync: alle lokalen Dateien auf NAS kopieren ──────────────────── +router.post('/webdav/sync', authenticate, requireAdmin, async (req, res) => { + const webdav = require('../tools/dateien/webdav'); + const nodePath = require('path'); + const fs = require('fs'); + const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + + const cfg = webdav.getConfig(); + if (!cfg.enabled) return res.status(400).json({ error: 'WebDAV nicht aktiviert' }); + + const results = { folders: 0, ok: 0, failed: 0, errors: [] }; + + try { + // ── Ordnerpfad rekursiv auflösen ─────────────────────────────────────── + function getFolderPath(folderId) { + if (!folderId) return ''; + const parts = []; + let cur = folderId; + const seen = new Set(); + while (cur) { + if (seen.has(cur)) break; seen.add(cur); + const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur); + if (!fo) break; + parts.unshift(fo.name.replace(/[\/]/g, '_')); + cur = fo.parent_id; + } + return parts.join('/'); + } + + // ── Schritt 1: User-Basisordner + alle DB-Ordner anlegen ─────────────── + const users = db.prepare('SELECT id, username FROM users ORDER BY id').all(); + for (const user of users) { + const userBase = webdav.userPath(cfg, user.username); + try { await webdav.mkdirp(userBase); results.folders++; } catch {} + try { await webdav.mkdirp(`${userBase}/_Upload-Freigaben`); } catch {} + + // Ordner iterativ sortieren (flach, nach Tiefe) + const allFolders = db.prepare('SELECT id, name, parent_id FROM folders WHERE user_id=? ORDER BY id').all(user.id); + const depthCache = {}; + function depth(fid) { + if (!fid) return 0; + if (depthCache[fid] !== undefined) return depthCache[fid]; + const fo = allFolders.find(f => f.id === fid); + depthCache[fid] = fo ? 1 + depth(fo.parent_id) : 0; + return depthCache[fid]; + } + const sorted = [...allFolders].sort((a, b) => depth(a.id) - depth(b.id)); + for (const folder of sorted) { + const fp = getFolderPath(folder.id); + if (fp) try { await webdav.mkdirp(`${userBase}/${fp}`); results.folders++; } catch {} + } + } + + // ── Schritt 2: Dateien hochladen ─────────────────────────────────────── + const files = db.prepare(` + SELECT f.*, u.username FROM files f + JOIN users u ON u.id = f.user_id + ORDER BY f.user_id, f.folder_id, f.id + `).all(); + + for (const file of files) { + const localPath = nodePath.join(UPLOAD_DIR, file.filename); + if (!fs.existsSync(localPath)) { + results.failed++; + results.errors.push(`${file.originalname}: lokale Datei fehlt`); + continue; + } + try { + const buf = fs.readFileSync(localPath); + const fp = getFolderPath(file.folder_id); + const base = webdav.userPath(cfg, file.username); + const davPath = fp ? `${base}/${fp}/${file.originalname}` : `${base}/${file.originalname}`; + await webdav.uploadFile(buf, davPath, file.mimetype || 'application/octet-stream'); + results.ok++; + } catch(e) { + results.failed++; + results.errors.push(`${file.originalname}: ${e.message}`); + } + } + + res.json({ + total: files.length, + ...results, + message: `${results.folders} Ordner angelegt, ${results.ok} Dateien kopiert${results.failed ? ', ' + results.failed + ' fehlgeschlagen' : ' – alles erfolgreich ✓'}`, + }); + + } catch(err) { + console.error('[webdav sync error]', err); + res.status(500).json({ error: err.message, ...results }); + } +}); + + +// ── Waisen aufräumen ────────────────────────────────────────────────────── +// Findet und löscht: +// 1. Dateien auf Disk die nicht in der DB sind (Disk-Waisen) +// 2. DB-Einträge die keine Datei auf Disk haben (DB-Waisen) +router.post('/cleanup-orphans', authenticate, requireAdmin, (req, res) => { + const { dry_run = false } = req.body; + const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + const path = require('path'); + const fs = require('fs'); + + const results = { + disk_orphans: [], // auf Disk aber nicht in DB + db_orphans: [], // in DB aber nicht auf Disk + disk_deleted: 0, + db_deleted: 0, + }; + + try { + // Alle Dateinamen aus DB + const dbFiles = db.prepare('SELECT id, filename, originalname, size FROM files').all(); + const dbFilenames = new Set(dbFiles.map(f => f.filename)); + + // Alle Dateien auf Disk + const diskFiles = fs.readdirSync(UPLOAD_DIR).filter(f => !f.startsWith('.')); + + // 1. Disk-Waisen: auf Disk aber nicht in DB + for (const diskFile of diskFiles) { + if (!dbFilenames.has(diskFile)) { + const fullPath = path.join(UPLOAD_DIR, diskFile); + const stat = fs.statSync(fullPath); + results.disk_orphans.push({ filename: diskFile, size: stat.size }); + if (!dry_run) { + try { fs.unlinkSync(fullPath); results.disk_deleted++; } catch(e) { + console.error('[cleanup] disk unlink failed:', diskFile, e.message); + } + } + } + } + + // 2. DB-Waisen: in DB aber nicht auf Disk + const diskFileset = new Set(diskFiles); + for (const dbFile of dbFiles) { + if (!diskFileset.has(dbFile.filename)) { + results.db_orphans.push({ id: dbFile.id, filename: dbFile.filename, originalname: dbFile.originalname }); + if (!dry_run) { + db.prepare('DELETE FROM files WHERE id=?').run(dbFile.id); + results.db_deleted++; + } + } + } + + res.json({ + dry_run, + disk_orphans_count: results.disk_orphans.length, + db_orphans_count: results.db_orphans.length, + disk_deleted: results.disk_deleted, + db_deleted: results.db_deleted, + disk_orphans: results.disk_orphans.slice(0, 50), + db_orphans: results.db_orphans.slice(0, 50), + }); + } catch(err) { + res.status(500).json({ error: err.message }); + } +}); + +// ── Disk-Analyse ────────────────────────────────────────────────────────── +router.get('/disk-analysis', authenticate, requireAdmin, (req, res) => { + const fs = require('fs'); + const path = require('path'); + + function dirSize(dirPath) { + let total = 0; + try { + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const full = path.join(dirPath, entry.name); + if (entry.isDirectory()) total += dirSize(full); + else try { total += fs.statSync(full).size; } catch {} + } + } catch {} + return total; + } + + function fmt(b) { + if (b < 1024) return b + ' B'; + if (b < 1024**2) return (b/1024).toFixed(1) + ' KB'; + if (b < 1024**3) return (b/1024**2).toFixed(1) + ' MB'; + return (b/1024**3).toFixed(2) + ' GB'; + } + + // Alle relevanten Verzeichnisse scannen + const locations = [ + '/data', + '/data/uploads', + '/tmp', + '/app', + '/root', + '/var/log', + '/var/lib/docker', + ]; + + const result = {}; + for (const loc of locations) { + if (fs.existsSync(loc)) { + result[loc] = { raw: dirSize(loc), fmt: fmt(dirSize(loc)) }; + } + } + + // DB-Größe + const dbPath = process.env.DB_PATH || '/data/db.sqlite'; + if (fs.existsSync(dbPath)) { + result['database'] = { raw: fs.statSync(dbPath).size, fmt: fmt(fs.statSync(dbPath).size) }; + } + + // /tmp Details + const tmpFiles = []; + try { + for (const f of fs.readdirSync('/tmp')) { + try { + const stat = fs.statSync('/tmp/' + f); + if (stat.size > 1024*1024) tmpFiles.push({ name: f, size: fmt(stat.size) }); + } catch {} + } + } catch {} + + // Node modules Größe + const nodeModules = '/app/node_modules'; + const nodeSize = fs.existsSync(nodeModules) ? dirSize(nodeModules) : 0; + + // Top 10 größte Dateien in /data + const bigFiles = []; + function scanBig(dir) { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) scanBig(full); + else try { + const s = fs.statSync(full).size; + if (s > 5*1024*1024) bigFiles.push({ path: full, size: fmt(s), raw: s }); + } catch {} + } + } catch {} + } + scanBig('/data'); + bigFiles.sort((a,b) => b.raw - a.raw); + + res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) }); +}); + +// GET /push-logs – Übersicht aller verschickten Pushover-Nachrichten (Admin) +router.get('/push-logs', authenticate, requireAdmin, (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 200, 1000); + const logs = db.prepare(` + SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority, + pl.source, pl.success, pl.created_at + FROM push_log pl + LEFT JOIN users u ON u.id = pl.user_id + ORDER BY pl.id DESC + LIMIT ? + `).all(limit); + res.json(logs); +}); + +// GET /logs – kombinierte Übersicht: Pushover-Nachrichten + Besuche +// öffentlicher Links, für den Logs-Bereich mit Filter-Dropdown (Admin) +router.get('/logs', authenticate, requireAdmin, (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 200, 1000); + + const pushRows = db.prepare(` + SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority, + pl.source, pl.success, pl.created_at + FROM push_log pl + LEFT JOIN users u ON u.id = pl.user_id + ORDER BY pl.id DESC + LIMIT ? + `).all(limit).map(r => ({ + logType: 'pushover', + id: `push-${r.id}`, + created_at: r.created_at, + username: r.username, + title: r.title, + message: r.message, + priority: r.priority, + source: r.source, + success: !!r.success, + })); + + const accessRows = db.prepare(` + SELECT id, link_type, path, ip, location, device, link_name, created_at + FROM public_access_log + ORDER BY id DESC + LIMIT ? + `).all(limit).map(r => ({ + logType: 'public_access', + id: `access-${r.id}`, + created_at: r.created_at, + linkType: r.link_type, + path: r.path, + ip: r.ip, + location: r.location, + device: r.device, + linkName: r.link_name, + })); + + const combined = [...pushRows, ...accessRows] + .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) + .slice(0, limit); + + res.json(combined); +}); + +// DELETE /logs/:id – einzelnen Log-Eintrag löschen (Admin). Die ID trägt ein +// Präfix ("push-123" / "access-45"), damit klar ist, aus welcher Tabelle +// gelöscht werden muss. +router.delete('/logs/:id', authenticate, requireAdmin, (req, res) => { + const raw = req.params.id; + const [prefix, numStr] = raw.split('-'); + const numId = parseInt(numStr, 10); + if (!numId) return res.status(400).json({ error: 'Ungültige ID' }); + + if (prefix === 'push') { + db.prepare('DELETE FROM push_log WHERE id=?').run(numId); + } else if (prefix === 'access') { + db.prepare('DELETE FROM public_access_log WHERE id=?').run(numId); + } else { + return res.status(400).json({ error: 'Ungültige ID' }); + } + res.json({ ok: true }); +}); + +// GET /console-log/download – Live-Konsolen-Mitschnitt (letzte 24h) als +// Datei herunterladen (Admin) +router.get('/console-log/download', authenticate, requireAdmin, (req, res) => { + const { getLogFilePath } = require('../consoleLog'); + const filePath = getLogFilePath(); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Noch keine Logs vorhanden' }); + const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); + res.download(filePath, `dickendock-console-${stamp}.log`); +}); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..59976bc --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,179 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); +const SECRET = process.env.JWT_SECRET || 'dev-secret'; + +const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function logAttempt(username, ip, success) { + db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))") + .run(username, ip || 'unknown', success ? 1 : 0); +} + +function getClientIp(req) { + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; +} + +function checkAndLock(user) { + const maxAttempts = parseInt(getSetting('login_max_attempts') || '5'); + const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30'); + + // Gesperrt? + if (user.locked_until) { + const until = new Date(user.locked_until.replace(' ', 'T')); + if (until > new Date()) return { locked: true, until }; + // Sperre abgelaufen → zurücksetzen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); + } + + // Fehlversuch zählen + const newAttempts = (user.failed_attempts || 0) + 1; + if (newAttempts >= maxAttempts) { + db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`) + .run(newAttempts, user.id); + const until = new Date(Date.now() + lockoutMinutes * 60 * 1000); + return { locked: true, until, newLock: true }; + } + db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?") + .run(newAttempts, user.id); + return { locked: false, remaining: maxAttempts - newAttempts }; +} + +// ── POST /api/auth/login ────────────────────────────────────────────────────── +router.post('/login', (req, res) => { + const { username, password } = req.body; + const ip = getClientIp(req); + + if (!username || !password) + return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + + // Unbekannter User → trotzdem loggen (kein Timing-Angriff) + if (!user) { + logAttempt(username, ip, false); + return res.status(401).json({ error: 'Benutzername oder Passwort falsch' }); + } + + // Gesperrt? + if (user.locked_until) { + const until = new Date(user.locked_until.replace(' ', 'T')); + if (until > new Date()) { + logAttempt(username, ip, false); + const min = Math.ceil((until - Date.now()) / 60000); + return res.status(403).json({ + error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`, + locked: true, + lockedUntil: until.toISOString(), + }); + } + // Abgelaufen → zurücksetzen + Attempts löschen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); + db.prepare('DELETE FROM login_attempts WHERE username=?').run(username); + } + + // Passwort prüfen + const valid = bcrypt.compareSync(password, user.password_hash); + logAttempt(username, ip, valid); + + if (!valid) { + const result = checkAndLock(user); + if (result.locked) { + const min = parseInt(getSetting('login_lockout_minutes') || '30'); + return res.status(403).json({ + error: result.newLock + ? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.` + : `Konto gesperrt. Bitte später versuchen.`, + locked: true, + }); + } + return res.status(401).json({ + error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`, + }); + } + + // Erfolgreich → Zähler zurücksetzen + Attempts löschen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); + db.prepare('DELETE FROM login_attempts WHERE username=?').run(username); + + const token = jwt.sign( + { id: user.id, username: user.username, role: user.role }, + SECRET, { expiresIn: '7d' } + ); + res.json({ token, user: { id: user.id, username: user.username, role: user.role } }); +}); + +// ── POST /api/auth/change-password ─────────────────────────────────────────── +router.post('/change-password', authenticate, (req, res) => { + const { currentPassword, newPassword } = req.body; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + if (!bcrypt.compareSync(currentPassword, user.password_hash)) + return res.status(400).json({ error: 'Aktuelles Passwort falsch' }); + if (!newPassword || newPassword.length < 8) + return res.status(400).json({ error: 'Mindestens 8 Zeichen erforderlich' }); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?') + .run(bcrypt.hashSync(newPassword, 12), req.user.id); + res.json({ success: true }); +}); + +// ── POST /api/auth/change-username ─────────────────────────────────────────── +router.post('/change-username', authenticate, (req, res) => { + const { newUsername, password } = req.body; + if (!newUsername || newUsername.trim().length < 3) + return res.status(400).json({ error: 'Mind. 3 Zeichen' }); + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id); + if (!bcrypt.compareSync(password, user.password_hash)) + return res.status(400).json({ error: 'Passwort falsch' }); + const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id); + if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' }); + db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id); + const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' }); + res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } }); +}); + +// ── DELETE /api/auth/account ────────────────────────────────────────────────── +router.delete('/account', authenticate, (req, res) => { + const { password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id); + if (!password || !bcrypt.compareSync(password, user.password_hash)) + return res.status(400).json({ error: 'Passwort falsch' }); + if (user.role === 'admin') { + const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c; + if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Admin kann nicht gelöscht werden' }); + } + db.prepare('DELETE FROM users WHERE id=?').run(req.user.id); + res.json({ success: true }); +}); + +// ── GET/PUT /api/auth/preferences ───────────────────────────────────────────── +router.get('/preferences', authenticate, (req, res) => { + const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id); + try { res.json(JSON.parse(user?.preferences || '{}')); } + catch { res.json({}); } +}); + +router.put('/preferences', authenticate, (req, res) => { + const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id); + let current = {}; + try { current = JSON.parse(user?.preferences || '{}'); } catch {} + const merged = { ...current, ...req.body }; + db.prepare('UPDATE users SET preferences=? WHERE id=?').run(JSON.stringify(merged), req.user.id); + res.json(merged); +}); + +// ── GET /api/auth/hidden-tools ──────────────────────────────────────────────── +// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche. +// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit +// darf nur der Admin ändern. +router.get('/hidden-tools', authenticate, (req, res) => { + const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id); + try { res.json(JSON.parse(user?.hidden_tools || '[]')); } + catch { res.json([]); } +}); + +module.exports = router; diff --git a/backend/src/routes/calendar.js b/backend/src/routes/calendar.js new file mode 100644 index 0000000..0562f31 --- /dev/null +++ b/backend/src/routes/calendar.js @@ -0,0 +1,69 @@ +const express = require('express'); +const https = require('https'); +const http = require('http'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); +const router = express.Router(); + +// Fetch mit Redirect-Unterstützung +function fetchWithRedirects(url, hops = 0) { + return new Promise((resolve, reject) => { + if (hops > 5) return reject(new Error('Zu viele Redirects')); + let parsed; + try { parsed = new URL(url); } catch { return reject(new Error('Ungültige URL')); } + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.get(url, { + headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar, text/plain, */*' } + }, res => { + if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) { + const next = res.headers.location.startsWith('http') + ? res.headers.location + : new URL(res.headers.location, url).href; + res.destroy(); + return fetchWithRedirects(next, hops + 1).then(resolve).catch(reject); + } + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); + let data = ''; + res.setEncoding('utf8'); + res.on('data', d => { data += d; if (data.length > 3*1024*1024) { res.destroy(); reject(new Error('Feed zu groß')); } }); + res.on('end', () => resolve(data)); + }); + req.on('error', reject); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + }); +} + +// GET /fetch?url=... +router.get('/fetch', authenticate, async (req, res) => { + const { url } = req.query; + if (!url) return res.status(400).json({ error: 'URL fehlt' }); + try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } + try { + const data = await fetchWithRedirects(url); + if (!data.includes('BEGIN:VCALENDAR')) + return res.status(502).json({ error: 'Kein gültiger iCal-Feed' }); + res.type('text/calendar; charset=utf-8').send(data); + } catch(e) { res.status(502).json({ error: e.message }); } +}); + +// GET /feeds +router.get('/feeds', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM calendar_feeds WHERE user_id=? ORDER BY id').all(req.user.id)); +}); + +// POST /feeds +router.post('/feeds', authenticate, (req, res) => { + const { name, url, color='#4ecdc4' } = req.body; + if (!name?.trim() || !url?.trim()) return res.status(400).json({ error: 'Name und URL erforderlich' }); + const r = db.prepare('INSERT INTO calendar_feeds (user_id,name,url,color) VALUES (?,?,?,?)').run(req.user.id, name.trim(), url.trim(), color); + res.json(db.prepare('SELECT * FROM calendar_feeds WHERE id=?').get(r.lastInsertRowid)); +}); + +// DELETE /feeds/:id +router.delete('/feeds/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM calendar_feeds WHERE id=? AND user_id=?').run(req.params.id, req.user.id); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..5262f22 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,330 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate, requireAdmin } = require('../middleware/auth'); +const router = express.Router(); +const uid = req => req.user.id; + +// ── Quick Links ─────────────────────────────────────────────────────────────── +router.get('/links', authenticate, (req, res) => { + const id = uid(req); + // Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1 + const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id); + const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id); + const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id); + res.json([...legacy, ...listLinks, ...folders]); +}); +router.post('/links', authenticate, (req, res) => { + const { title, url, icon = '🔗' } = req.body; + if (!title || !url) return res.status(400).json({ error: 'Titel und URL erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM quick_links WHERE user_id=?').get(uid(req)); + const r = db.prepare('INSERT INTO quick_links (user_id,title,url,icon,sort_order) VALUES (?,?,?,?,?)') + .run(uid(req), title, url, icon, (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(r.lastInsertRowid)); +}); +router.put('/links/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM quick_links WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const { title=ex.title, url=ex.url, icon=ex.icon } = req.body; + db.prepare('UPDATE quick_links SET title=?,url=?,icon=? WHERE id=? AND user_id=?') + .run(title, url, icon, req.params.id, uid(req)); + res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(req.params.id)); +}); +router.delete('/links/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM quick_links WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +router.put('/links-order', authenticate, (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids erforderlich' }); + const update = db.prepare('UPDATE quick_links SET sort_order=? WHERE id=? AND user_id=?'); + db.transaction(() => { ids.forEach((id, i) => update.run(i, id, uid(req))); })(); + res.json({ ok: true }); +}); + +// ── Todos ───────────────────────────────────────────────────────────────────── +router.get('/todos', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM todos WHERE user_id=? ORDER BY done,sort_order,id').all(uid(req))); +}); +router.post('/todos', authenticate, (req, res) => { + const { text } = req.body; + if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM todos WHERE user_id=?').get(uid(req)); + const r = db.prepare('INSERT INTO todos (user_id,text,sort_order) VALUES (?,?,?)') + .run(uid(req), text.trim(), (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(r.lastInsertRowid)); +}); +router.put('/todos/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM todos WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const text = req.body.text ?? ex.text; + const done = req.body.done !== undefined ? (req.body.done ? 1 : 0) : ex.done; + db.prepare('UPDATE todos SET text=?,done=? WHERE id=? AND user_id=?').run(text, done, req.params.id, uid(req)); + res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(req.params.id)); +}); +router.delete('/todos/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM todos WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +// ── Notizen ─────────────────────────────────────────────────────────────────── +router.get('/note', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM notes WHERE user_id=?').get(uid(req)) || { content: '', updated_at: null }); +}); +router.put('/note', authenticate, (req, res) => { + db.prepare(`INSERT INTO notes (user_id,content,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET content=excluded.content,updated_at=CURRENT_TIMESTAMP`) + .run(uid(req), req.body.content ?? ''); + res.json({ success: true, updated_at: new Date().toISOString() }); +}); + + +// ── Statistiken ─────────────────────────────────────────────────────────────── +router.get('/stats', authenticate, (req, res) => { + const uid = req.user.id; + + const orders = db.prepare(` + SELECT status, bezahlt, + SUM(COALESCE(custom_price_sum_view,0)) AS revenue + FROM ( + SELECT o.status, o.bezahlt, + SUM(COALESCE(oi.custom_price,0)*COALESCE(oi.stueckzahl,0)) AS custom_price_sum_view + FROM orders o + LEFT JOIN order_items oi ON oi.order_id=o.id + WHERE o.user_id=? + GROUP BY o.id + ) + GROUP BY status, bezahlt + `).all(uid); + + const totalOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(uid).c; + const bezahltOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=1 AND (abgeholt=0 OR abgeholt IS NULL)').get(uid).c; + // Revenue: Gesamt-Festpreis hat Priorität, sonst Summe der Positions-Festpreise + const paidOrders = db.prepare('SELECT * FROM orders WHERE user_id=? AND bezahlt=1').all(uid); + let totalRevenue = 0; + for (const o of paidOrders) { + if (o.custom_price != null) { + totalRevenue += parseFloat(o.custom_price); + } else { + const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s; + totalRevenue += sum; + } + } + + const openOrders = db.prepare("SELECT * FROM orders WHERE user_id=? AND bezahlt=0 AND status!='warteliste'").all(uid); + let offeneRevenue = 0; + for (const o of openOrders) { + if (o.custom_price != null) { + offeneRevenue += parseFloat(o.custom_price); + } else { + const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s; + offeneRevenue += sum; + } + } + const thisMonth = new Date(); + thisMonth.setDate(1); thisMonth.setHours(0,0,0,0); + const ordersThisMonth = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND created_at>=?').get(uid, thisMonth.toISOString()).c; + const totalCalcs = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(uid).c; + + const byStatus = { warteliste:0, in_arbeit:0, fertig:0 }; + for (const r of db.prepare('SELECT status, COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=0 GROUP BY status').all(uid)) { + byStatus[r.status] = r.c; + } + + // Grundkosten berechnen (preis_freundschaft = Selbstkosten pro Stück aus Kalkulation) + function getBaseCost(orderId) { + const items = db.prepare(` + SELECT oi.stueckzahl, oi.custom_price, + COALESCE(c.preis_freundschaft, oi.preis_freundschaft) as basis + FROM order_items oi + LEFT JOIN calculations c ON c.id = oi.calculation_id + WHERE oi.order_id = ? + `).all(orderId); + return items.reduce((sum, i) => sum + (i.basis * i.stueckzahl), 0); + } + + function getRevenue(order) { + if (order.custom_price != null) return parseFloat(order.custom_price); + return db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(order.id).s; + } + + let totalProfit = 0; + for (const o of paidOrders) { + const rev = getRevenue(o); + const cost = getBaseCost(o.id); + totalProfit += rev - cost; + } + + let offeneProfit = 0; + for (const o of openOrders) { + const rev = getRevenue(o); + const cost = getBaseCost(o.id); + offeneProfit += rev - cost; + } + + res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus, totalProfit, offeneProfit }); +}); + +// ── Ideen-Board ─────────────────────────────────────────────────────────────── +router.get('/board', authenticate, (req, res) => { + const u = uid(req); + const items = db.prepare(` + SELECT b.*, us.username as author + FROM board_items b JOIN users us ON us.id=b.user_id + ORDER BY b.type ASC, b.created_at DESC + `).all(); + + const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read; + const unread = lastRead + ? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c + : db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c; + + res.json({ items, unread }); +}); + +router.get('/board/unread', authenticate, (req, res) => { + const u = uid(req); + const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read; + const unread = lastRead + ? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c + : db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c; + const unreadPromoted = lastRead + ? db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at > ?").get(lastRead).c + : db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at IS NOT NULL").get().c; + res.json({ unread, unreadPromoted }); +}); + +router.post('/board/read', authenticate, (req, res) => { + const u = uid(req); + db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u); + res.json({ ok: true }); +}); + +router.post('/board', authenticate, (req, res) => { + const u = uid(req); + const { type, title, description = '' } = req.body; + if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' }); + if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' }); + if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' }); + const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`) + .run(type, u, title.trim(), description.trim()); + res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid), + author: req.user.username }); +}); + +router.put('/board/:id', authenticate, (req, res) => { + const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Nicht gefunden' }); + const isAdmin = req.user.role === 'admin'; + const isOwn = item.user_id === uid(req); + if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' }); + const { title, description } = req.body; + db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?') + .run(title ?? item.title, description ?? item.description, item.id); + res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username }); +}); + +router.delete('/board/:id', authenticate, (req, res) => { + const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Nicht gefunden' }); + const isAdmin = req.user.role === 'admin'; + const isOwn = item.user_id === uid(req); + if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('DELETE FROM board_items WHERE id=?').run(item.id); + res.json({ ok: true }); +}); + +// Wunsch zur Roadmap befördern (Admin only) +router.post('/board/:id/promote', authenticate, (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Nicht gefunden' }); + if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' }); + db.prepare("UPDATE board_items SET type='roadmap', promoted_from_wish=1, promoted_at=datetime('now','localtime') WHERE id=?").run(item.id); + const updated = db.prepare(` + SELECT b.*, u.username as author FROM board_items b + JOIN users u ON u.id=b.user_id WHERE b.id=? + `).get(item.id); + res.json(updated); +}); + +// ── Changelog ───────────────────────────────────────────────────────────────── +router.get('/changelog', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM changelog ORDER BY created_at DESC').all()); +}); + +router.get('/changelog/unread', authenticate, (req, res) => { + const u = req.user.id; + const lastRead = db.prepare('SELECT last_read FROM changelog_reads WHERE user_id=?').get(u)?.last_read; + const count = lastRead + ? db.prepare('SELECT COUNT(*) c FROM changelog WHERE created_at > ?').get(lastRead).c + : db.prepare('SELECT COUNT(*) c FROM changelog').get().c; + res.json({ unread: count }); +}); + +router.post('/changelog/read', authenticate, (req, res) => { + db.prepare(`INSERT OR REPLACE INTO changelog_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`) + .run(req.user.id); + res.json({ ok: true }); +}); + +router.post('/changelog', authenticate, requireAdmin, (req, res) => { + const { version, title, body, build_time } = req.body; + if (!version?.trim() || !title?.trim()) return res.status(400).json({ error: 'Version und Titel erforderlich' }); + const r = db.prepare(`INSERT INTO changelog (version, title, body, build_time, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`) + .run(version.trim(), title.trim(), body?.trim() || '', build_time || null); + res.json(db.prepare('SELECT * FROM changelog WHERE id=?').get(r.lastInsertRowid)); +}); + +router.delete('/changelog/:id', authenticate, requireAdmin, (req, res) => { + db.prepare('DELETE FROM changelog WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); + +// ── Push-Zeitplaner ─────────────────────────────────────────────────────────── +router.get('/push-schedules', authenticate, (req, res) => { + const rows = db.prepare(` + SELECT * FROM push_schedules + WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime') + ORDER BY scheduled_at ASC + `).all(uid(req)); + res.json(rows); +}); + +router.post('/push-schedules', authenticate, (req, res) => { + const { message, scheduled_at } = req.body; + if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' }); + // Normalisieren: T→Leerzeichen, Sekunden ergänzen + const normalized = scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : ''); + const r = db.prepare(` + INSERT INTO push_schedules (user_id, message, scheduled_at, created_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(uid(req), message.trim(), normalized); + res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/push-schedules/:id', authenticate, (req, res) => { + const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!row) return res.status(404).json({ error: 'Nicht gefunden' }); + const { message, scheduled_at } = req.body; + const normalized = scheduled_at ? scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '') : row.scheduled_at; + db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?') + .run(message ?? row.message, normalized, row.id); + res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id)); +}); + +router.delete('/push-schedules/:id', authenticate, (req, res) => { + db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + res.json({ ok: true }); +}); + +// Unquittierte Favoriten für Admin-Dashboard-Badge +router.get('/media-favorites/unacked', authenticate, (req, res) => { + if (req.user.role !== 'admin') return res.json({ count: 0 }); + const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get(); + res.json({ count: count.n }); +}); + +module.exports = router; diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js new file mode 100644 index 0000000..4d6daef --- /dev/null +++ b/backend/src/routes/search.js @@ -0,0 +1,260 @@ +const express = require('express'); +const https = require('https'); +const http = require('http'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); +const router = express.Router(); + +const safe = fn => { try { return fn(); } catch { return []; } }; +const safeGet = fn => { try { return fn(); } catch { return null; } }; + +// ── iCal Fetch + Parse ──────────────────────────────────────────────────────── +function fetchIcal(url) { + return new Promise(resolve => { + try { + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.get(url, { + headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar,*/*' } + }, res => { + // Redirects verfolgen + if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) { + res.destroy(); + const next = res.headers.location.startsWith('http') + ? res.headers.location : new URL(res.headers.location, url).href; + fetchIcal(next).then(resolve); + return; + } + let data = ''; + res.on('data', d => { data += d; if (data.length > 1e6) { res.destroy(); resolve(null); } }); + res.on('end', () => resolve(data.includes('BEGIN:VCALENDAR') ? data : null)); + }); + req.on('error', () => resolve(null)); + req.setTimeout(10000, () => { req.destroy(); resolve(null); }); + } catch { resolve(null); } + }); +} + +function parseIcal(raw) { + const events = []; + const parts = raw.split('BEGIN:VEVENT'); + for (let i = 1; i < parts.length; i++) { + const b = parts[i]; + const get = key => { + // Handle folded lines and key variants like DTSTART;TZID=... + const m = b.match(new RegExp(key + '[^:]*:([^\\r\\n]+)')); + return m ? m[1].replace(/\\n/g,' ').replace(/\\,/g,',').replace(/\\;/g,';').trim() : ''; + }; + const summary = get('SUMMARY'); + if (!summary) continue; + events.push({ summary, description:get('DESCRIPTION'), location:get('LOCATION'), start:get('DTSTART'), end:get('DTEND') }); + } + return events; +} + +// ── Kalender-Sync Endpoint (POST /api/search/sync-calendar) ────────────────── +// Frontend ruft das beim Dashboard-Laden auf — kein Sync beim Suchen selbst +router.post('/sync-calendar', authenticate, async (req, res) => { + const uid = req.user.id; + const force = req.body?.force === true; + + const feeds = safeGet(() => db.prepare('SELECT * FROM calendar_feeds WHERE user_id=?').all(uid)) || []; + if (!feeds.length) return res.json({ ok: true, synced: 0, message: 'Keine Kalender-Abos vorhanden' }); + + // Alter des Caches prüfen + // Alte Cache-Prüfung: Wenn noch historische Events drin (start_dt < 2000), force-sync + const hasOldData = safeGet(() => + db.prepare("SELECT 1 FROM calendar_event_cache WHERE user_id=? AND start_dt < '20200101' LIMIT 1").get(uid) + ); + if (!force && !hasOldData) { + const lastSync = safeGet(() => + db.prepare("SELECT MAX(synced_at) as s FROM calendar_event_cache WHERE user_id=?").get(uid)?.s + ); + if (lastSync && (Date.now() - new Date(lastSync).getTime()) < 10 * 60 * 1000) { + return res.json({ ok: true, synced: 0, message: 'Cache noch frisch' }); + } + } + + let totalSynced = 0; + const errors = []; + + for (const feed of feeds) { + try { + const raw = await fetchIcal(feed.url); + if (!raw) { errors.push(`${feed.name}: kein gültiger iCal-Feed`); continue; } + const events = parseIcal(raw); + if (!events.length) { errors.push(`${feed.name}: 0 Events geparst`); continue; } + + // Nur zukünftige Events speichern (ab gestern rückwärts 1 Tag für Tagesevents) + // start_dt Format: YYYYMMDD oder YYYYMMDDTHHmmssZ → string-vergleichbar + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const minDt = yesterday.toISOString().replace(/-/g,'').slice(0,8); // '20260604' + const futureEvents = events.filter(ev => !ev.start || ev.start >= minDt); + + // prepare INSIDE try-catch – wirft wenn Tabelle nicht existiert + const ins = db.prepare(` + INSERT INTO calendar_event_cache + (feed_id, user_id, summary, description, location, start_dt, end_dt, synced_at) + VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) + `); + db.transaction(() => { + db.prepare('DELETE FROM calendar_event_cache WHERE feed_id=?').run(feed.id); + futureEvents.slice(0, 500).forEach(ev => + ins.run(feed.id, uid, ev.summary||'', ev.description||'', ev.location||'', ev.start||'', ev.end||'') + ); + })(); + totalSynced += futureEvents.length; + } catch(e) { + console.error(`[CalendarSync] Feed "${feed.name}" (${feed.url}): ${e.message}`); + errors.push(`${feed.name}: ${e.message}`); + } + } + + console.log(`[CalendarSync] uid=${uid} synced=${totalSynced} feeds=${feeds.length} errors=${errors.length}`); + res.json({ ok: true, synced: totalSynced, feeds: feeds.length, errors }); +}); + +// ── Suche ───────────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const q = (req.query.q || '').trim(); + const uid = req.user.id; + + if (q.length < 2) return res.json({ + links:[], folders:[], snippets:[], calculations:[], orders:[], + todos:[], notes:[], board_items:[], changelog:[], + files:[], file_folders:[], push_schedules:[], + calendar_feeds:[], calendar_events:[], qr_codes:[] + }); + + const like = `%${q.toLowerCase()}%`; + + const links = safe(() => db.prepare(` + SELECT id, title, url, icon, description FROM link_list + WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(description) LIKE ?) + ORDER BY title LIMIT 8 + `).all(uid, like, like, like)); + + const folders = safe(() => db.prepare(` + SELECT id, name, icon FROM link_list_folders + WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5 + `).all(uid, like)); + + const snippets = safe(() => db.prepare(` + SELECT id, title, description, language FROM snippets + WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?) + ORDER BY title LIMIT 6 + `).all(uid, like, like)); + + const calculations = safe(() => db.prepare(` + SELECT id, name FROM calculations + WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5 + `).all(uid, like)); + + const orders = safe(() => db.prepare(` + SELECT id, name, status FROM orders + WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5 + `).all(uid, like)); + + const todos = safe(() => db.prepare(` + SELECT id, text, done FROM todos + WHERE user_id=? AND LOWER(text) LIKE ? + ORDER BY done, created_at DESC LIMIT 6 + `).all(uid, like)); + + const noteRow = safeGet(() => db.prepare( + 'SELECT content FROM notes WHERE user_id=? AND LOWER(content) LIKE ?' + ).get(uid, like)); + const notes = noteRow ? [(() => { + const idx = noteRow.content.toLowerCase().indexOf(q.toLowerCase()); + const s = Math.max(0, idx - 30); + return { id:'note', preview: (s>0?'…':'') + noteRow.content.slice(s, idx+60) + '…' }; + })()] : []; + + const board_items = safe(() => db.prepare(` + SELECT id, title, description, type FROM board_items + WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?) + ORDER BY created_at DESC LIMIT 5 + `).all(uid, like, like)); + + const changelog = safe(() => db.prepare(` + SELECT id, version, title, body FROM changelog + WHERE LOWER(title) LIKE ? OR LOWER(body) LIKE ? + ORDER BY created_at DESC LIMIT 4 + `).all(like, like)); + + const files = safe(() => db.prepare(` + SELECT id, originalname, mimetype, size FROM files + WHERE user_id=? AND (LOWER(originalname) LIKE ? OR LOWER(filename) LIKE ?) + ORDER BY originalname LIMIT 6 + `).all(uid, like, like)); + + const file_folders = safe(() => db.prepare(` + SELECT id, name FROM folders + WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5 + `).all(uid, like)); + + const push_schedules = safe(() => db.prepare(` + SELECT id, message, scheduled_at FROM push_schedules + WHERE user_id=? AND LOWER(message) LIKE ? AND sent=0 + ORDER BY scheduled_at LIMIT 5 + `).all(uid, like)); + + const calendar_feeds = safe(() => db.prepare(` + SELECT id, name, url FROM calendar_feeds + WHERE user_id=? AND (LOWER(name) LIKE ? OR LOWER(url) LIKE ?) + ORDER BY name LIMIT 4 + `).all(uid, like, like)); + + const calendar_events = safe(() => db.prepare(` + SELECT c.id, c.summary, c.description, c.location, c.start_dt, f.name as feed_name + FROM calendar_event_cache c + JOIN calendar_feeds f ON f.id=c.feed_id + WHERE c.user_id=? AND f.user_id=? + AND (LOWER(c.summary) LIKE ? OR LOWER(c.description) LIKE ? OR LOWER(c.location) LIKE ?) + AND c.start_dt >= strftime('%Y%m%d', 'now') + ORDER BY c.start_dt LIMIT 8 + `).all(uid, uid, like, like, like)); + + const qr_codes = safe(() => db.prepare(` + SELECT id, label, url FROM qr_codes + WHERE user_id=? AND (LOWER(label) LIKE ? OR LOWER(url) LIKE ?) + ORDER BY created_at DESC LIMIT 5 + `).all(uid, like, like)); + + // Gebietseroberung-Spiele + const geo_games = safe(() => db.prepare(` + SELECT g.id, u1.username as owner_name, u2.username as opp_name, g.status, g.current_turn + FROM geo_games g + JOIN users u1 ON u1.id=g.owner_id + JOIN users u2 ON u2.id=g.opponent_id + WHERE (g.owner_id=? OR g.opponent_id=?) + AND (LOWER(u1.username) LIKE ? OR LOWER(u2.username) LIKE ?) + ORDER BY g.updated_at DESC LIMIT 5 + `).all(uid, uid, like, like)); + + // Andere Benutzer – für Chat-Navigation (nur Benutzernamen, keine sensiblen Daten) + const users = safe(() => db.prepare(` + SELECT id, username FROM users + WHERE id != ? AND LOWER(username) LIKE ? + ORDER BY username LIMIT 5 + `).all(uid, like)); + + res.json({ links, folders, snippets, calculations, orders, todos, notes, + board_items, changelog, files, file_folders, push_schedules, + calendar_feeds, calendar_events, qr_codes, users, geo_games }); +}); + +// Debug-Endpoint: Kalender-Cache inspizieren +router.get('/calendar-debug', authenticate, (req, res) => { + const uid = req.user.id; + const count = db.prepare('SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=?').get(uid); + const samples = db.prepare('SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? ORDER BY start_dt LIMIT 10').all(uid); + const today = db.prepare("SELECT strftime('%Y%m%d','now') as d").get(); + const future = db.prepare("SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=? AND start_dt >= strftime('%Y%m%d','now')").get(uid); + const search = db.prepare("SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? AND LOWER(summary) LIKE '%arzt%'").all(uid); + res.json({ count: count.n, today: today.d, future_count: future.n, samples, arzt_matches: search }); +}); + + +module.exports = router; diff --git a/backend/src/routes/system.js b/backend/src/routes/system.js new file mode 100644 index 0000000..561875e --- /dev/null +++ b/backend/src/routes/system.js @@ -0,0 +1,40 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +let VERSION = 'v1.0.0'; +try { VERSION = fs.readFileSync(path.join(__dirname, '../../version.txt'), 'utf8').trim(); } catch {} + +const GITEA_URL = process.env.GITEA_URL; +const GITEA_REPO = process.env.GITEA_REPO; + +router.get('/version', authenticate, (_req, res) => res.json({ version: VERSION })); + +router.get('/update-check', authenticate, async (_req, res) => { + if (!GITEA_URL || !GITEA_REPO) + return res.json({ hasUpdate: false, currentVersion: VERSION, configured: false }); + try { + const r = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=5`, + { signal: AbortSignal.timeout(5000) }); + const releases = await r.json(); + if (!Array.isArray(releases) || !releases.length) + return res.json({ hasUpdate: false, currentVersion: VERSION, configured: true }); + res.json({ + hasUpdate: releases[0].tag_name !== VERSION, + currentVersion: VERSION, + latestVersion: releases[0].tag_name, + configured: true, + releases: releases.map(r => ({ + version: r.tag_name, name: r.name || r.tag_name, + body: r.body || '(Keine Beschreibung)', publishedAt: r.published_at, + })), + }); + } catch (e) { + res.json({ hasUpdate: false, currentVersion: VERSION, configured: true }); + } +}); + +module.exports = router; diff --git a/backend/src/tools/bestellungen/routes.js b/backend/src/tools/bestellungen/routes.js new file mode 100644 index 0000000..c3efc25 --- /dev/null +++ b/backend/src/tools/bestellungen/routes.js @@ -0,0 +1,169 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); +const uid = req => req.user.id; + +// Status der Bestellung automatisch aus den Stück-Zählern berechnen +const autoUpdateOrderStatus = (orderId) => { + const items = db.prepare('SELECT * FROM order_items WHERE order_id=?').all(orderId); + if (!items.length) return; + const totalPieces = items.reduce((s,i) => s + (i.stueckzahl||0), 0); + const fertigPieces = items.reduce((s,i) => s + (i.qty_fertig||0), 0); + const arbeitPieces = items.reduce((s,i) => s + (i.qty_in_arbeit||0), 0); + let newStatus = 'warteliste'; + if (fertigPieces >= totalPieces && totalPieces > 0) newStatus = 'fertig'; + else if (fertigPieces > 0 || arbeitPieces > 0) newStatus = 'in_arbeit'; + db.prepare('UPDATE orders SET status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?').run(newStatus, orderId); +}; + +const loadOrder = (id, userId) => { + const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(id, userId); + if (!order) return null; + order.items = db.prepare('SELECT * FROM order_items WHERE order_id=? ORDER BY id').all(id); + return order; +}; + +// GET / – Liste mit Zählern + Items +router.get('/', authenticate, (req, res) => { + const orders = db.prepare(` + SELECT o.*, + COUNT(oi.id) AS item_count, + SUM(COALESCE(oi.qty_fertig,0)) AS qty_fertig_total, + SUM(COALESCE(oi.stueckzahl,0)) AS qty_total, + SUM(COALESCE(oi.custom_price,0) * COALESCE(oi.stueckzahl,0)) AS custom_price_sum, + SUM(COALESCE(oi.stunden,0) * COALESCE(oi.stueckzahl,0)) AS stunden_total + FROM orders o + LEFT JOIN order_items oi ON oi.order_id = o.id + WHERE o.user_id = ? + GROUP BY o.id + ORDER BY o.created_at DESC + `).all(uid(req)); + // Attach items to each order + const items = db.prepare('SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE user_id=?) ORDER BY order_id, id').all(uid(req)); + const itemsByOrder = {}; + for (const item of items) { + if (!itemsByOrder[item.order_id]) itemsByOrder[item.order_id] = []; + itemsByOrder[item.order_id].push(item); + } + for (const order of orders) order.items = itemsByOrder[order.id] || []; + res.json(orders); +}); + +router.get('/:id', authenticate, (req, res) => { + const order = loadOrder(req.params.id, uid(req)); + if (!order) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(order); +}); + +router.post('/', authenticate, (req, res) => { + const { name, bemerkung='', status='warteliste', custom_price=null } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' }); + const r = db.prepare('INSERT INTO orders (user_id,name,bemerkung,status,custom_price) VALUES (?,?,?,?,?)') + .run(uid(req), name.trim(), bemerkung, status, custom_price); + res.json(loadOrder(r.lastInsertRowid, uid(req))); +}); + +router.put('/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + const { name=ex.name, bemerkung=ex.bemerkung, status=ex.status, custom_price=ex.custom_price } = req.body; + + // Bezahlt-Status mit Datum (optional überschreibbar) + let bezahlt = ex.bezahlt; + let bezahlt_am = ex.bezahlt_am; + if (req.body.bezahlt !== undefined) { + bezahlt = req.body.bezahlt ? 1 : 0; + bezahlt_am = req.body.bezahlt + ? (req.body.bezahlt_am_override || new Date().toISOString()) + : null; + } + + // Abgeholt-Status mit Datum (optional überschreibbar) + let abgeholt = ex.abgeholt; + let abgeholt_am = ex.abgeholt_am; + if (req.body.abgeholt !== undefined) { + abgeholt = req.body.abgeholt ? 1 : 0; + abgeholt_am = req.body.abgeholt + ? (req.body.abgeholt_am_override || new Date().toISOString()) + : null; + } + + db.prepare('UPDATE orders SET name=?,bemerkung=?,status=?,custom_price=?,bezahlt=?,bezahlt_am=?,abgeholt=?,abgeholt_am=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?') + .run(name, bemerkung, status, custom_price, bezahlt, bezahlt_am, abgeholt, abgeholt_am, req.params.id, uid(req)); + res.json(loadOrder(req.params.id, uid(req))); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM orders WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +router.post('/:id/items', authenticate, (req, res) => { + const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!order) return res.status(404).json({ error: 'Nicht gefunden' }); + const { calculation_id, stueckzahl=1, custom_price=null } = req.body; + const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(calculation_id, uid(req)); + if (!calc) return res.status(404).json({ error: 'Archiv-Eintrag nicht gefunden' }); + const existing = db.prepare('SELECT * FROM order_items WHERE order_id=? AND calculation_id=?').get(req.params.id, calculation_id); + if (existing) { + const newQty = existing.stueckzahl + stueckzahl; + db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=qty_warteliste+? WHERE id=?').run(newQty, stueckzahl, existing.id); + } else { + db.prepare(`INSERT INTO order_items + (order_id,calculation_id,calc_name,preis_freundschaft,preis_normal,preis_auftrag, + stueckzahl,custom_price,status,qty_warteliste,qty_in_arbeit,qty_fertig,stunden) + VALUES (?,?,?,?,?,?,?,?,'warteliste',?,0,0,?)`) + .run(req.params.id, calc.id, calc.name, calc.preis_freundschaft, calc.preis_normal, calc.preis_auftrag, + stueckzahl, custom_price, stueckzahl, calc.stunden || 0); + } + autoUpdateOrderStatus(req.params.id); + res.json(loadOrder(req.params.id, uid(req))); +}); + +router.put('/:id/items/:itemId', authenticate, (req, res) => { + const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!order) return res.status(404).json({ error: 'Nicht gefunden' }); + const item = db.prepare('SELECT * FROM order_items WHERE id=? AND order_id=?').get(req.params.itemId, req.params.id); + if (!item) return res.status(404).json({ error: 'Position nicht gefunden' }); + + // Stückzahl geändert → Warteliste anpassen + if (req.body.stueckzahl !== undefined) { + const newQty = Math.max(1, req.body.stueckzahl); + const diff = newQty - item.stueckzahl; + const newWarte = Math.max(0, item.qty_warteliste + diff); + db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=? WHERE id=?').run(newQty, newWarte, item.id); + } + + // Status-Zähler direkt setzen + if (req.body.qty_warteliste !== undefined || req.body.qty_in_arbeit !== undefined || req.body.qty_fertig !== undefined) { + const fresh = db.prepare('SELECT * FROM order_items WHERE id=?').get(item.id); + const w = req.body.qty_warteliste ?? fresh.qty_warteliste; + const a = req.body.qty_in_arbeit ?? fresh.qty_in_arbeit; + const f = req.body.qty_fertig ?? fresh.qty_fertig; + // Summe darf stueckzahl nicht überschreiten + const total = w + a + f; + if (total <= fresh.stueckzahl) { + db.prepare('UPDATE order_items SET qty_warteliste=?,qty_in_arbeit=?,qty_fertig=? WHERE id=?').run(w, a, f, item.id); + } + } + + // Festpreis + if (req.body.custom_price !== undefined) { + db.prepare('UPDATE order_items SET custom_price=? WHERE id=?').run(req.body.custom_price, item.id); + } + + autoUpdateOrderStatus(req.params.id); + res.json(loadOrder(req.params.id, uid(req))); +}); + +router.delete('/:id/items/:itemId', authenticate, (req, res) => { + const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!order) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM order_items WHERE id=? AND order_id=?').run(req.params.itemId, req.params.id); + autoUpdateOrderStatus(req.params.id); + res.json(loadOrder(req.params.id, uid(req))); +}); + +module.exports = router; diff --git a/backend/src/tools/dateien/public-share-public.js b/backend/src/tools/dateien/public-share-public.js new file mode 100644 index 0000000..1a6658e --- /dev/null +++ b/backend/src/tools/dateien/public-share-public.js @@ -0,0 +1,103 @@ +// Öffentliche Endpunkte – kein Login nötig +const express = require('express'); +const bcrypt = require('bcryptjs'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const { logPublicAccess } = require('../../publicAccessLog'); +const router = express.Router(); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + +function getShare(token) { + return db.prepare(` + SELECT s.*, + f.originalname as file_name, f.size as file_size, f.mimetype as mime_type, f.filename as file_path, + fo.name as folder_name + FROM public_file_shares s + LEFT JOIN files f ON f.id = s.file_id + LEFT JOIN folders fo ON fo.id = s.folder_id + WHERE s.token = ? + `).get(token); +} + +function isExpired(s) { + return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000); +} + +// GET /:token – Metadaten +router.get('/:token', (req, res) => { + const s = getShare(req.params.token); + if (!s) return res.status(404).json({ error: 'Link nicht gefunden' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + logPublicAccess({ linkType: 'file_share', path: `/s/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] }); + res.json({ + label: s.label, + file_name: s.file_name, + file_size: s.file_size, + mime_type: s.mime_type, + folder_name: s.folder_name, + is_folder: !!s.folder_id, + needs_password: !!s.password_hash, + expires_at: s.expires_at, + }); +}); + +// POST /:token/verify – nur Passwort prüfen, kein Download +router.post('/:token/verify', async (req, res) => { + const s = getShare(req.params.token); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); + res.json({ ok: true, file_name: s.file_name, file_size: s.file_size, mime_type: s.mime_type, is_folder: !!s.folder_id }); +}); + +// POST /:token/download – Datei herunterladen +router.post('/:token/download', async (req, res) => { + const s = getShare(req.params.token); + if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); + const filePath = path.join(UPLOAD_DIR, s.file_path); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); + db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`); + res.setHeader('Content-Type', s.mime_type || 'application/octet-stream'); + fs.createReadStream(filePath).pipe(res); +}); + +// POST /:token/folder – Ordnerinhalt +router.post('/:token/folder', async (req, res) => { + const s = getShare(req.params.token); + if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); + const files = db.prepare(` + SELECT id, originalname as name, size, mimetype as mime_type, filename as file_path FROM files + WHERE folder_id=? AND user_id=? ORDER BY name + `).all(s.folder_id, s.user_id); + db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); + res.json({ folder_name: s.folder_name, files, token: s.token }); +}); + +// POST /:token/folder/:fileId – Einzelne Datei aus Ordner +router.post('/:token/folder/:fileId', async (req, res) => { + const s = getShare(req.params.token); + if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); + const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?') + .get(req.params.fileId, s.folder_id, s.user_id); + if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' }); + const filePath = path.join(UPLOAD_DIR, file.file_path || file.filename); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name || file.originalname)}"`); + res.setHeader('Content-Type', file.mime_type || file.mimetype || 'application/octet-stream'); + fs.createReadStream(filePath).pipe(res); +}); + +module.exports = router; diff --git a/backend/src/tools/dateien/public-share.js b/backend/src/tools/dateien/public-share.js new file mode 100644 index 0000000..e0ac57c --- /dev/null +++ b/backend/src/tools/dateien/public-share.js @@ -0,0 +1,88 @@ +// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links +const express = require('express'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +function generateToken() { return crypto.randomBytes(24).toString('base64url'); } + +// GET / – eigene Shares auflisten +router.get('/', authenticate, (req, res) => { + try { + const shares = db.prepare(` + SELECT s.id, s.token, s.user_id, s.file_id, s.folder_id, s.label, + s.password_hash, s.expires_at, s.created_at, s.download_count, + f.originalname as file_name, f.size as file_size, + fo.name as folder_name + FROM public_file_shares s + LEFT JOIN files f ON f.id = s.file_id + LEFT JOIN folders fo ON fo.id = s.folder_id + WHERE s.user_id = ? + ORDER BY s.created_at DESC + `).all(req.user.id); + res.json({ shares }); + } catch(e) { + console.error('[public-shares GET]', e.message); + res.status(500).json({ error: e.message }); + } +}); + +// POST / – neuen Share erstellen +router.post('/', authenticate, async (req, res) => { + const { file_id, folder_id, password, expires_hours, label } = req.body; + if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' }); + if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' }); + if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' }); + + if (file_id) { + const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id); + if (!f) return res.status(403).json({ error: 'Keine Berechtigung' }); + } + if (folder_id) { + const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id); + if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' }); + } + + const token = generateToken(); + const password_hash = await bcrypt.hash(password.trim(), 10); + const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600; + + const r = db.prepare(` + INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at); + + const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid); + res.json({ share, token }); +}); + +// DELETE /:id – Share löschen +router.delete('/:id', authenticate, (req, res) => { + const s = db.prepare('SELECT id FROM public_file_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM public_file_shares WHERE id=?').run(s.id); + res.json({ ok: true }); +}); + +// Debug: Tabellenstatus + eigene Shares mit Token +router.get('/debug-table', (req, res) => { + try { + const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get(); + const count = exists ? db.prepare('SELECT COUNT(*) as n FROM public_file_shares').get() : null; + const cols = exists ? db.pragma('table_info(public_file_shares)').map(c=>c.name) : []; + const sample = exists ? db.prepare('SELECT id, token, user_id, file_id, folder_id, expires_at, download_count FROM public_file_shares LIMIT 5').all() : []; + // Test: ersten Token direkt abfragen wie public endpoint es tut + let publicTest = null; + if (sample.length > 0) { + const t = sample[0].token; + const s = db.prepare('SELECT s.*, f.originalname as file_name, fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id WHERE s.token=?').get(t); + const now = Math.floor(Date.now()/1000); + publicTest = { token: t, expires_at: s?.expires_at, now, isExpired: s?.expires_at < now, typeof_expires: typeof s?.expires_at }; + } + res.json({ exists: !!exists, count: count?.n, cols, sample, publicTest }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +module.exports = router; diff --git a/backend/src/tools/dateien/routes.js b/backend/src/tools/dateien/routes.js new file mode 100644 index 0000000..baff25b --- /dev/null +++ b/backend/src/tools/dateien/routes.js @@ -0,0 +1,444 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); + +const router = express.Router(); +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; +if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +const webdav = require('./webdav'); + +// ── WebDAV Hilfsfunktionen ──────────────────────────────────────────────── +function getFolderPathById(folderId) { + if (!folderId) return ''; + const parts = []; + let cur = folderId; + const seen = new Set(); + while (cur) { + if (seen.has(cur)) break; seen.add(cur); + const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur); + if (!fo) break; + parts.unshift(fo.name.replace(/[\/]/g, '_')); + cur = fo.parent_id; + } + return parts.join('/'); +} + +function davFileUrl(username, folderId, filename) { + const cfg = webdav.getConfig(); + if (!cfg.enabled) return null; + const base = webdav.userPath(cfg, username); + const folder = getFolderPathById(folderId); + return folder ? `${base}/${folder}/${filename}` : `${base}/${filename}`; +} + +function davFolderUrl(username, folderId) { + const cfg = webdav.getConfig(); + if (!cfg.enabled) return null; + const base = webdav.userPath(cfg, username); + const folder = getFolderPathById(folderId); + return folder ? `${base}/${folder}` : base; +} + +function getUserName(userId) { + return db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || String(userId); +} + +const getSetting = key => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value; + +const diskStorage = multer.diskStorage({ + destination: UPLOAD_DIR, + filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`), +}); +const memStorage = multer.memoryStorage(); + +const storage = diskStorage; // Standard: Disk +const getUpload = () => multer({ + storage, + limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = (getSetting('file_allowed_ext') || '').split(',').map(e => e.trim().toLowerCase()).filter(Boolean); + if (!allowed.length) return cb(null, true); + const ext = path.extname(file.originalname).toLowerCase(); + allowed.includes(ext) ? cb(null, true) : cb(new Error(`Dateityp nicht erlaubt: ${ext}`)); + }, +}); + +// Walk folder tree upward to check if any ancestor is shared with user +function isInSharedFolder(folderId, uid) { + let cur = folderId; + while (cur) { + if (db.prepare('SELECT id FROM folder_shares WHERE folder_id=? AND shared_with=?').get(cur, uid)) return true; + const p = db.prepare('SELECT parent_id FROM folders WHERE id=?').get(cur); + cur = p?.parent_id || null; + } + return false; +} + +const enrichFolder = (folder) => ({ + ...folder, + fileCount: db.prepare('SELECT COUNT(*) c FROM files WHERE folder_id=?').get(folder.id).c, + subCount: db.prepare('SELECT COUNT(*) c FROM folders WHERE parent_id=?').get(folder.id).c, + totalSize: db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE folder_id=?').get(folder.id).s, +}); + +// ── Ordner ──────────────────────────────────────────────────────────────────── +router.get('/folders', authenticate, (req, res) => { + const uid = req.user.id; + const parentId = req.query.parent_id ? parseInt(req.query.parent_id) : null; + + // Own folders in this directory + const own = parentId + ? db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id=? ORDER BY name').all(uid, parentId) + : db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id IS NULL ORDER BY name').all(uid); + + // Shared folders + let shared = []; + if (!parentId) { + shared = db.prepare(` + SELECT f.*, u.username as owner, + GROUP_CONCAT(su.username) as shared_with_names, + MIN(fs.created_at) as shared_at + FROM folders f JOIN users u ON u.id=f.user_id + JOIN folder_shares fs ON fs.folder_id=f.id + JOIN users su ON su.id=fs.shared_with + WHERE fs.shared_with=? + GROUP BY f.id ORDER BY f.name + `).all(uid); + } else if (isInSharedFolder(parentId, uid)) { + const parentFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(parentId); + if (parentFolder) { + shared = db.prepare('SELECT f.*, u.username as owner FROM folders f JOIN users u ON u.id=f.user_id WHERE f.parent_id=? AND f.user_id!=? ORDER BY f.name').all(parentId, uid); + } + } + + // Folders shared BY me (root only) + const sharedByMe = parentId ? [] : db.prepare(` + SELECT DISTINCT f.*, u.username as owner, + GROUP_CONCAT(su.username) as shared_with_names, + MIN(fs.created_at) as shared_at + FROM folders f JOIN users u ON u.id=f.user_id + JOIN folder_shares fs ON fs.folder_id=f.id + JOIN users su ON su.id=fs.shared_with + WHERE f.user_id=? + GROUP BY f.id ORDER BY f.name + `).all(uid); + + res.json({ own: own.map(enrichFolder), shared: shared.map(enrichFolder), sharedByMe: sharedByMe.map(enrichFolder) }); +}); + +router.post('/folders', authenticate, (req, res) => { + const { name, parent_id } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' }); + const parentId = parent_id ? parseInt(parent_id) : null; + if (parentId) { + const parent = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(parentId, req.user.id); + if (!parent) return res.status(404).json({ error: 'Überordner nicht gefunden' }); + } + const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(req.user.id, name.trim(), parentId); + const newFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid); + // WebDAV: Ordner anlegen + const _davFolderCreate = davFolderUrl(getUserName(req.user.id), newFolder.id); + if (_davFolderCreate) webdav.mkdirp(_davFolderCreate).catch(e => console.warn('[webdav] mkdir:', e.message)); + res.json(enrichFolder(newFolder)); +}); + +router.delete('/folders/:id', authenticate, (req, res) => { + const uid = req.user.id; + const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, uid); + if (!folder) return res.status(404).json({ error: 'Nicht gefunden' }); + + // Alle Unterordner rekursiv sammeln + function collectSubfolderIds(parentId) { + const children = db.prepare('SELECT id FROM folders WHERE parent_id=? AND user_id=?').all(parentId, uid); + let ids = [parentId]; + for (const c of children) ids = ids.concat(collectSubfolderIds(c.id)); + return ids; + } + const allFolderIds = collectSubfolderIds(folder.id); + + // WebDAV: Ordner löschen (vor DB-Löschung damit Pfad noch auflösbar) + const _davFolderDel = davFolderUrl(getUserName(uid), folder.id); + if (_davFolderDel) webdav.deleteFile(_davFolderDel).catch(e => console.warn('[webdav] rmdir:', e.message)); + + // Alle Dateien in allen Unterordnern löschen (Disk + DB) + for (const fid of allFolderIds) { + const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(fid, uid); + for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} } + db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(fid, uid); + } + // Alle Unterordner + Hauptordner aus DB löschen + for (const fid of allFolderIds.reverse()) { + db.prepare('DELETE FROM folders WHERE id=?').run(fid); + } + res.json({ success: true }); +}); + +router.get('/folders/:id/shares', authenticate, (req, res) => { + const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!folder) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(db.prepare('SELECT u.id,u.username,fs.created_at as shared_at FROM folder_shares fs JOIN users u ON u.id=fs.shared_with WHERE fs.folder_id=?').all(folder.id)); +}); + +router.post('/folders/:id/share', authenticate, (req, res) => { + const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!folder) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + db.prepare('INSERT OR IGNORE INTO folder_shares (folder_id,shared_by,shared_with) VALUES (?,?,?)').run(folder.id, req.user.id, target.id); + res.json({ success: true }); +}); + +router.delete('/folders/:id/share/:userId', authenticate, (req, res) => { + const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!folder) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM folder_shares WHERE folder_id=? AND shared_with=?').run(folder.id, req.params.userId); + res.json({ success: true }); +}); + +// ── Dateien ─────────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const uid = req.user.id; + const folderId = req.query.folder_id ? parseInt(req.query.folder_id) : null; + + // Own files in this directory + const own = folderId + ? db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id=? ORDER BY f.created_at DESC').all(uid, folderId) + : db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id IS NULL ORDER BY f.created_at DESC').all(uid); + + // Files shared BY me + const sharedByMe = folderId ? [] : db.prepare(` + SELECT DISTINCT f.*, u.username as owner, + GROUP_CONCAT(su.username) as shared_with_names, + MIN(s.created_at) as shared_at, + MAX(s.accessed_at) as accessed_at, + MAX(CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END) as has_password + FROM files f JOIN users u ON u.id=f.user_id + JOIN file_shares s ON s.file_id=f.id + JOIN users su ON su.id=s.shared_with + WHERE f.user_id=? + GROUP BY f.id ORDER BY f.created_at DESC + `).all(uid); + + // Files shared with me + let shared = []; + if (!folderId) { + shared = db.prepare(` + SELECT f.*, u.username as owner, sb.username as shared_by_name, + CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password + FROM files f JOIN users u ON u.id=f.user_id + JOIN file_shares s ON s.file_id=f.id + JOIN users sb ON sb.id=s.shared_by + WHERE s.shared_with=? + ORDER BY f.created_at DESC + `).all(uid); + } else if (isInSharedFolder(folderId, uid)) { + const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId); + if (folder) { + shared = db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.folder_id=? AND f.user_id=? ORDER BY f.created_at DESC').all(folderId, folder.user_id); + } + } + + res.json({ own, shared, sharedByMe }); +}); + +router.post('/', authenticate, (req, res) => { + const uid = req.user.id; + const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50'); + const totalLimitMb= parseInt(getSetting('file_total_size_mb') || '500'); + const usedBytes = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s; + if (usedBytes >= totalLimitMb * 1024 * 1024) + return res.status(400).json({ error: `Gesamtlimit von ${totalLimitMb} MB erreicht` }); + getUpload().single('file')(req, res, err => { + if (err) { + if (req.file) try { fs.unlinkSync(req.file.path); } catch {} + return res.status(400).json({ error: err.message }); + } + if (!req.file) return res.status(400).json({ error: 'Keine Datei' }); + const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null; + const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)') + .run(uid, req.file.filename, req.file.originalname, req.file.mimetype||'application/octet-stream', req.file.size, folderId); + const newFile = db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid); + + // WebDAV: Datei asynchron spiegeln + const _davPath = davFileUrl(getUserName(uid), folderId, req.file.originalname); + if (_davPath) { + fs.readFile(path.join(UPLOAD_DIR, req.file.filename), (err, buf) => { + if (!err) webdav.uploadFile(buf, _davPath, req.file.mimetype).catch(e => console.error('[webdav upload]', e.message)); + }); + } + + res.json(newFile); + }); +}); + +// ── 3D-Modelle Hilfsendpoints ───────────────────────────────────────────────── + +// Sucht einen Ordner ohne ihn anzulegen – gibt null zurück wenn nicht gefunden +router.get('/find-folder', authenticate, (req, res) => { + const uid = req.user.id; + const { name, parent_id } = req.query; + if (!name?.trim()) return res.json(null); + const parentId = parent_id ? parseInt(parent_id) : null; + const folder = parentId + ? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId) + : db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim()); + res.json(folder ? enrichFolder(folder) : null); +}); + +// Stellt sicher dass ein Ordner existiert (Name + optionaler parent), gibt ihn zurück +router.post('/ensure-folder', authenticate, (req, res) => { + const uid = req.user.id; + const { name, parent_id } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' }); + const parentId = parent_id ? parseInt(parent_id) : null; + + // Suche existierenden Ordner + const existing = parentId + ? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId) + : db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim()); + + if (existing) return res.json(enrichFolder(existing)); + + const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(uid, name.trim(), parentId); + res.json(enrichFolder(db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid))); +}); + +// Upload 3D-Dateien (ohne Ext-Beschränkung, für den Kalkulator) +const upload3d = multer({ + storage, + limits: { fileSize: parseInt(process.env.MAX_3D_SIZE_MB || '200') * 1024 * 1024 }, +}); + +router.post('/upload-3d', authenticate, (req, res) => { + upload3d.array('files', 20)(req, res, err => { + if (err) { + if (req.files?.length) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} }); + return res.status(400).json({ error: err.message }); + } + if (!req.files?.length) return res.status(400).json({ error: 'Keine Dateien' }); + const uid = req.user.id; + const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null; + const inserted = []; + for (const f of req.files) { + const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)') + .run(uid, f.filename, f.originalname, f.mimetype || 'application/octet-stream', f.size, folderId); + inserted.push(r.lastInsertRowid); + } + res.json({ uploaded: inserted.length }); + }); +}); + +router.get('/storage', authenticate, (req, res) => { + const uid = req.user.id; + const used = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s; + const count = db.prepare('SELECT COUNT(*) AS c FROM files WHERE user_id=?').get(uid).c; + const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50'); + const totalLimitMb = parseInt(getSetting('file_total_size_mb') || '500'); + res.json({ used, count, maxSizeMb, totalLimitMb }); +}); + +router.get('/:id/download', authenticate, async (req, res) => { + const uid = req.user.id; + const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.id); + if (!file) return res.status(404).json({ error: 'Nicht gefunden' }); + + const isOwner = file.user_id === uid; + const share = db.prepare('SELECT * FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, uid); + const inSharedDir = file.folder_id && isInSharedFolder(file.folder_id, uid); + + if (!isOwner && !share && !inSharedDir) return res.status(403).json({ error: 'Kein Zugriff' }); + + // Passwortprüfung für Shares (Owner braucht kein Passwort) + if (!isOwner && share?.password_hash) { + const bcrypt = require('bcryptjs'); + const pw = req.query.password || req.headers['x-share-password'] || ''; + const ok = pw && await bcrypt.compare(pw, share.password_hash); + if (!ok) return res.status(403).json({ + error: 'Falsches Passwort', + passwordRequired: !pw // true = noch kein PW eingegeben, false = falsches PW + }); + } + + const fp = path.join(UPLOAD_DIR, file.filename); + if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' }); + + // Zugriff tracken (immer aktualisieren damit es sicher gesetzt wird) + if (share) { + db.prepare("UPDATE file_shares SET accessed_at = datetime('now','localtime') WHERE file_id=? AND shared_with=?") + .run(file.id, uid); + } + + res.download(fp, file.originalname); +}); + +router.delete('/:id', authenticate, (req, res) => { + const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!file) return res.status(404).json({ error: 'Nicht gefunden' }); + // WebDAV: Datei löschen + const _davFileDel = davFileUrl(getUserName(file.user_id), file.folder_id, file.originalname); + if (_davFileDel) webdav.deleteFile(_davFileDel).catch(e => console.warn('[webdav] delete:', e.message)); + try { fs.unlinkSync(path.join(UPLOAD_DIR, file.filename)); } catch {} + db.prepare('DELETE FROM files WHERE id=?').run(file.id); + res.json({ success: true }); +}); + +router.get('/:id/shares', authenticate, (req, res) => { + const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!file) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(db.prepare(` + SELECT u.id, u.username, s.created_at as shared_at, + s.accessed_at, + CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password + FROM file_shares s JOIN users u ON u.id=s.shared_with + WHERE s.file_id=? + `).all(file.id)); +}); + +router.post('/:id/share', authenticate, async (req, res) => { + const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!file) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + let passwordHash = null; + if (req.body.password) { + const bcrypt = require('bcryptjs'); + passwordHash = await bcrypt.hash(req.body.password, 10); + } + const existing = db.prepare('SELECT id FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, target.id); + if (existing) { + db.prepare('UPDATE file_shares SET password_hash=?, accessed_at=NULL WHERE file_id=? AND shared_with=?') + .run(passwordHash, file.id, target.id); + } else { + db.prepare('INSERT INTO file_shares (file_id,shared_by,shared_with,password_hash) VALUES (?,?,?,?)') + .run(file.id, req.user.id, target.id, passwordHash); + } + res.json({ success: true }); +}); + +router.delete('/:id/share/:userId', authenticate, (req, res) => { + const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!file) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM file_shares WHERE file_id=? AND shared_with=?').run(file.id, req.params.userId); + res.json({ success: true }); +}); + +// Settings +router.get('/settings', authenticate, requireAdmin, (req, res) => { + const s = db.prepare('SELECT * FROM admin_settings').all(); + const obj = {}; for (const r of s) obj[r.key] = r.value; + res.json(obj); +}); +router.put('/settings', authenticate, requireAdmin, (req, res) => { + for (const key of ['file_max_size_mb','file_total_size_mb','file_allowed_ext']) { + if (req.body[key] !== undefined) + db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(key, String(req.body[key])); + } + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/dateien/upload-share.js b/backend/src/tools/dateien/upload-share.js new file mode 100644 index 0000000..cee0ae3 --- /dev/null +++ b/backend/src/tools/dateien/upload-share.js @@ -0,0 +1,173 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const { logPublicAccess } = require('../../publicAccessLog'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); +const router = express.Router(); + + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + +function generateToken() { return crypto.randomBytes(20).toString('base64url'); } + +function getOrCreateUploadFolder(userId) { + let f = db.prepare("SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1").get(userId); + if (!f) { + const r = db.prepare("INSERT INTO folders (user_id,name,is_upload_folder,created_at) VALUES (?,?,1,datetime('now','localtime'))").run(userId,'Upload'); + f = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid); + } + return f; +} + +function canUseUploadShare(userId) { + const u = db.prepare('SELECT role,allow_upload_share FROM users WHERE id=?').get(userId); + return u && (u.role==='admin' || !!u.allow_upload_share); +} + +// ── Eigene Shares ───────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' }); + const folder = getOrCreateUploadFolder(req.user.id); + const shares = db.prepare(` + SELECT s.*, COUNT(l.id) as upload_count + FROM upload_shares s + LEFT JOIN upload_share_logs l ON l.share_id=s.id + WHERE s.user_id=? + GROUP BY s.id ORDER BY s.created_at DESC + `).all(req.user.id); + res.json({ shares, folder }); +}); + +router.post('/', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' }); + const { password, expires_hours=24, max_size_mb=10 } = req.body; + if (!password?.trim()) return res.status(400).json({ error:'Passwort erforderlich' }); + const folder = getOrCreateUploadFolder(req.user.id); + const token = generateToken(); + const hash = bcrypt.hashSync(password.trim(), 10); + const expires = new Date(Date.now() + Number(expires_hours)*3600000).toISOString(); + const r = db.prepare(` + INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at) + VALUES (?,?,?,?,?,?,datetime('now','localtime')) + `).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id); + res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!s) return res.status(404).json({ error:'Nicht gefunden' }); + db.prepare("UPDATE upload_shares SET is_active=0, deactivated_reason='manual' WHERE id=?").run(s.id); + res.json({ ok:true }); +}); + +router.delete('/:id/remove', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!s) return res.status(404).json({ error:'Nicht gefunden' }); + db.prepare('DELETE FROM upload_shares WHERE id=?').run(s.id); + res.json({ ok:true }); +}); + +router.get('/logs', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' }); + const logs = db.prepare(` + SELECT l.*, s.expires_at, s.max_size_mb + FROM upload_share_logs l + JOIN upload_shares s ON s.id=l.share_id + WHERE l.user_id=? ORDER BY l.uploaded_at DESC LIMIT 100 + `).all(req.user.id); + res.json(logs); +}); + +// ── Admin ───────────────────────────────────────────────────────────────────── +router.get('/admin/logs', authenticate, requireAdmin, (req, res) => { + const logs = db.prepare(` + SELECT l.*, s.expires_at, s.max_size_mb, u.username + FROM upload_share_logs l + JOIN upload_shares s ON s.id=l.share_id + JOIN users u ON u.id=l.user_id + ORDER BY l.uploaded_at DESC LIMIT 500 + `).all(); + res.json(logs); +}); + +router.get('/admin/shares', authenticate, requireAdmin, (req, res) => { + const shares = db.prepare(` + SELECT s.*, u.username, COUNT(l.id) as upload_count + FROM upload_shares s JOIN users u ON u.id=s.user_id + LEFT JOIN upload_share_logs l ON l.share_id=s.id + GROUP BY s.id ORDER BY s.created_at DESC + `).all(); + res.json(shares); +}); + +router.post('/admin/permission', authenticate, requireAdmin, (req, res) => { + const { user_id, allow } = req.body; + db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id); + res.json({ ok:true }); +}); + +// ── Öffentlich: Share-Info ──────────────────────────────────────────────────── +router.get('/public/:token', (req, res) => { + const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token); + if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' }); + if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' }); + logPublicAccess({ linkType: 'upload_share', path: `/u/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] }); + res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at }); +}); + +// ── Öffentlich: Passwort prüfen ─────────────────────────────────────────────── +router.post('/public/:token/verify', (req, res) => { + const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token); + if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' }); + if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' }); + + if (!bcrypt.compareSync(req.body?.password || '', s.password_hash)) { + const attempts = (s.failed_attempts||0) + 1; + if (attempts >= 3) { + // Link sperren + db.prepare("UPDATE upload_shares SET is_active=0, failed_attempts=?, deactivated_reason='too_many_attempts' WHERE id=?").run(attempts, s.id); + return res.status(401).json({ error:'Zu viele Fehlversuche – Link wurde gesperrt', locked:true }); + } + db.prepare("UPDATE upload_shares SET failed_attempts=? WHERE id=?").run(attempts, s.id); + return res.status(401).json({ error:'Falsches Passwort', attempts_left: 3-attempts }); + } + + // Erfolgreich – Fehlversuche zurücksetzen + db.prepare("UPDATE upload_shares SET failed_attempts=0 WHERE id=?").run(s.id); + res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at }); +}); + +// ── Öffentlich: Upload ──────────────────────────────────────────────────────── +router.post('/public/:token/upload', (req, res) => { + const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token); + if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' }); + if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' }); + + const pw = req.headers['x-upload-password'] || ''; + if (!bcrypt.compareSync(pw, s.password_hash)) return res.status(401).json({ error:'Nicht autorisiert' }); + + const storage = multer.diskStorage({ destination: UPLOAD_DIR }); + const upload = multer({ storage, limits:{ fileSize: s.max_size_mb*1024*1024 } }).single('file'); + + upload(req, res, err => { + if (err) { + if (err.code==='LIMIT_FILE_SIZE') return res.status(413).json({ error:`Datei zu groß (max ${s.max_size_mb} MB)` }); + return res.status(500).json({ error:err.message }); + } + if (!req.file) return res.status(400).json({ error:'Keine Datei' }); + try { + db.prepare(`INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id,created_at) VALUES (?,?,?,?,?,?,datetime('now','localtime'))`).run(s.user_id,req.file.filename,req.file.originalname,req.file.mimetype,req.file.size,s.folder_id); + db.prepare(`INSERT INTO upload_share_logs (share_id,user_id,originalname,size,ip_address,uploaded_at) VALUES (?,?,?,?,?,datetime('now','localtime'))`).run(s.id,s.user_id,req.file.originalname,req.file.size,req.ip||''); + res.json({ ok:true, filename:req.file.originalname }); + } catch(e) { + try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {} + res.status(500).json({ error:e.message }); + } + }); +}); + +module.exports = router; diff --git a/backend/src/tools/dateien/webdav.js b/backend/src/tools/dateien/webdav.js new file mode 100644 index 0000000..d6565f9 --- /dev/null +++ b/backend/src/tools/dateien/webdav.js @@ -0,0 +1,169 @@ +/** + * WebDAV Storage Backend für Synology NAS + * Abstrahiert alle Datei-Operationen – lokal oder WebDAV + */ +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const db = require('../../db'); + +function getSetting(key) { + return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value || ''; +} + +function getConfig() { + return { + url: getSetting('webdav_url'), // z.B. http://192.168.1.100:5005 + user: getSetting('webdav_user'), + password: getSetting('webdav_password'), + basePath: getSetting('webdav_base_path') || '/dickendock', // z.B. /dickendock + enabled: getSetting('webdav_enabled') === '1', + }; +} + +function authHeader(cfg) { + return 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64'); +} + +// WebDAV Request ausführen +function davRequest(method, davPath, opts = {}) { + return new Promise((resolve, reject) => { + const cfg = getConfig(); + const base = new URL(cfg.url); + const fullPath = base.pathname.replace(/\/$/, '') + davPath; + const mod = base.protocol === 'https:' ? https : http; + + const headers = { + 'Authorization': authHeader(cfg), + 'Accept': '*/*', + ...opts.headers, + }; + + const reqOpts = { + hostname: base.hostname, + port: base.port || (base.protocol === 'https:' ? 443 : 80), + path: fullPath, + method, + headers, + rejectUnauthorized: false, // Synology Self-Signed Certs + }; + + if (opts.body) { + headers['Content-Length'] = Buffer.byteLength(opts.body); + } + + const req = mod.request(reqOpts, res => { + let data = ''; + res.setEncoding('binary'); + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + + req.on('error', reject); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +// ── Öffentliche API ──────────────────────────────────────────────────────── + +/** + * Ordner auf WebDAV anlegen (inkl. alle Parent-Ordner) + */ +async function mkdirp(davPath) { + const parts = davPath.split('/').filter(Boolean); + let current = ''; + for (const part of parts) { + current += '/' + part; + const r = await davRequest('MKCOL', current); + // 201 = erstellt, 301/405/409/423 = schon vorhanden oder Konflikt (ignorieren) + const ok = [201, 301, 405, 409, 423].includes(r.status); + if (!ok) { + console.warn(`[webdav mkdirp] MKCOL ${current} → ${r.status} (ignoriert)`); + } + } +} + +/** + * User-Basispfad auf WebDAV: /dickendock/{username} + */ +function userPath(cfg, username) { + return cfg.basePath.replace(/\/$/, '') + '/' + username.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +/** + * Datei auf WebDAV hochladen + * @param {Buffer} buffer - Dateiinhalt + * @param {string} davPath - Zielpfad auf WebDAV + * @param {string} mimeType + */ +async function uploadFile(buffer, davPath, mimeType = 'application/octet-stream') { + try { await mkdirp(davPath.split('/').slice(0, -1).join('/')); } catch(e) { + console.warn('[webdav] mkdirp warning:', e.message); + } + const r = await new Promise((resolve, reject) => { + const cfg = getConfig(); + const base = new URL(cfg.url); + const fullPath = base.pathname.replace(/\/$/, '') + davPath; + const mod = base.protocol === 'https:' ? https : http; + + const headers = { + 'Authorization': authHeader(cfg), + 'Content-Type': mimeType, + 'Content-Length': buffer.length, + }; + + const req = mod.request({ + hostname: base.hostname, + port: base.port || (base.protocol === 'https:' ? 443 : 80), + path: fullPath, + method: 'PUT', + headers, + rejectUnauthorized: false, + }, res => { res.resume(); resolve({ status: res.statusCode }); }); + + req.on('error', reject); + req.write(buffer); + req.end(); + }); + + if (r.status !== 201 && r.status !== 204) { + throw new Error(`PUT ${davPath} → ${r.status}`); + } +} + +/** + * Datei von WebDAV downloaden (gibt Buffer zurück) + */ +async function downloadFile(davPath) { + const r = await davRequest('GET', davPath); + if (r.status !== 200) throw new Error(`GET ${davPath} → ${r.status}`); + return Buffer.from(r.body, 'binary'); +} + +/** + * Datei/Ordner auf WebDAV löschen + */ +async function deleteFile(davPath) { + const r = await davRequest('DELETE', davPath); + if (r.status !== 204 && r.status !== 200 && r.status !== 404) { + throw new Error(`DELETE ${davPath} → ${r.status}`); + } +} + +/** + * Verbindung testen + */ +async function testConnection() { + const cfg = getConfig(); + if (!cfg.url || !cfg.user || !cfg.password) throw new Error('WebDAV nicht konfiguriert'); + const r = await davRequest('PROPFIND', cfg.basePath || '/', { + headers: { 'Depth': '0', 'Content-Type': 'application/xml' }, + body: '', + }); + if (r.status !== 207 && r.status !== 200) throw new Error(`Verbindung fehlgeschlagen: HTTP ${r.status}`); + return true; +} + +module.exports = { getConfig, userPath, mkdirp, uploadFile, downloadFile, deleteFile, testConnection }; diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js new file mode 100644 index 0000000..97d81e9 --- /dev/null +++ b/backend/src/tools/gebietseroberung/routes.js @@ -0,0 +1,617 @@ +const express = require('express'); +const db = require('../../db'); +const { logPush } = require('../../pushLog'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + const cols = db.pragma('table_info(geo_games)').map(c => c.name); + if (!cols.length) { + db.exec(` + CREATE TABLE IF NOT EXISTS geo_games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + opponent_id INTEGER NOT NULL, + grid TEXT NOT NULL, + terrain TEXT NOT NULL DEFAULT '[]', + current_turn INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + winner_id INTEGER, + move_count INTEGER NOT NULL DEFAULT 0, + last_event TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + `); + } + for (const [col, def] of [ + ['move_count', 'INTEGER NOT NULL DEFAULT 0'], + ['terrain', 'TEXT'], + ['last_event', 'TEXT'], + ['owner_head', 'TEXT'], + ['opp_head', 'TEXT'], + ['owner_scouts', 'TEXT'], + ['opp_scouts', 'TEXT'], + ['seen_by', 'TEXT'], + ]) { + if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); + } + // fog_owner/fog_opp waren alte Spalten, ignorieren +})(); + +// ── Konstanten ──────────────────────────────────────────────────────────────── +const GRID = 20; +const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; +const T_NORMAL = 0; +const T_GOLD = 1; +const T_MINE = 2; +const T_ROCK = 3; + +// ── Terrain generieren ──────────────────────────────────────────────────────── +function generateTerrain(ownerHead, oppHead) { + const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL)); + const safe = new Set(); + // 3x3 Sicherheitszone um beide Startecken + for (const headStr of [ownerHead, oppHead]) { + const [hr,hc] = headStr.split(',').map(Number); + for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) { + const r=hr+dr, c=hc+dc; + if (r>=0&&r=0&&c0;i--) { const j=Math.floor(Math.random()*(i+1)); [cells[i],cells[j]]=[cells[j],cells[i]]; } + + let idx=0; + for (let i=0;i<12&&idx Array(GRID).fill(false)); + for (let r=0;r=0&&nr=0&&nc=0&&nr=0&&nc0;i--) { + const j=Math.floor(Math.random()*(i+1)); + [corners[i],corners[j]]=[corners[j],corners[i]]; + } + const [or,oc] = corners[0]; + const [pr,pc] = corners[1]; + const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0)); + g[or][oc] = ownerId; + g[pr][pc] = oppId; + return { grid:g, ownerHead:`${or},${oc}`, oppHead:`${pr},${pc}` }; +} + +// Prüft ob [row,col] an den Kopf [hr,hc] angrenzt (8 Richtungen) +function isAdjacentToHead(row, col, hr, hc) { + return Math.abs(row - hr) <= 1 && Math.abs(col - hc) <= 1 && !(row === hr && col === hc); +} + +// Fallback für canMove: prüft ob Spieler noch irgendwo vom Kopf aus ziehen kann +function isAdjacent(grid, row, col, playerId) { + for (const [dr,dc] of DIRS8) { + const r=row+dr, c=col+dc; + if (r>=0&&r=0&&c=0&&r=0&&c=GRID||c<0||c>=GRID) continue; + if (terrain[r][c] === T_ROCK) continue; // schon Felsen + + // Ketten-Mine merken BEVOR terrain geändert wird + if (terrain[r][c] === T_MINE && !exploded.has(`${r},${c}`)) { + chainMines.push([r,c]); + } + + // Punktabzug für besetzte Felder + if (grid[r][c] === ownerId) destroyedByOwner++; + else if (grid[r][c] === oppId) destroyedByOpp++; + + // Feld wird zu Felsen + grid[r][c] = 0; + terrain[r][c] = T_ROCK; + blastCells.push([r,c]); + } + + // Kettenexplosionen + for (const [mr,mc] of chainMines) { + const chain = explodeMine(grid, terrain, mr, mc, ownerId, oppId, exploded); + destroyedByOwner += chain.destroyedByOwner; + destroyedByOpp += chain.destroyedByOpp; + blastCells.push(...chain.blastCells); + } + + return { destroyedByOwner, destroyedByOpp, blastCells }; +} + +function sendPush(userId, title, message) { + const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); + if (!cfg?.app_token || !cfg?.user_key) return; + fetch('https://api.pushover.net/1/messages.json', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), + }).catch(() => {}); + logPush({ userId, title, message, priority: 0, source: 'gebietseroberung' }); +} + +// Spiel laden + Fog anwenden + echte Scores beifügen +function getGameForUser(id, requesterId) { + const game = db.prepare(` + SELECT g.*, u1.username as owner_name, u2.username as opp_name + FROM geo_games g + JOIN users u1 ON u1.id = g.owner_id + JOIN users u2 ON u2.id = g.opponent_id + WHERE g.id = ? + `).get(id); + if (!game) return null; + + const grid = JSON.parse(game.grid); + const terrain = JSON.parse(game.terrain || '[]'); + + // Echte Scores auf ungemasktem Grid berechnen + const ownerScore = terrain.length ? calcScore(grid, terrain, game.owner_id) : 0; + const oppScore = terrain.length ? calcScore(grid, terrain, game.opponent_id) : 0; + // Dem Requester mitteilen welcher Score seiner ist + const myScore = requesterId === game.owner_id ? ownerScore : oppScore; + const oppSc = requesterId === game.owner_id ? oppScore : ownerScore; + + const myHead = requesterId === game.owner_id ? game.owner_head : game.opp_head; + const oppHead = requesterId === game.owner_id ? game.opp_head : game.owner_head; + + // Altes Spiel ohne Terrain: direkt zurückgeben, kein Fog + if (!terrain.length) { + return { ...game, myScore:0, oppScore:0, myHead, oppHead }; + } + + // Scouts des anfragenden Spielers laden + const myScoutsRaw = requesterId === game.owner_id ? game.owner_scouts : game.opp_scouts; + const myScouts = myScoutsRaw ? JSON.parse(myScoutsRaw) : []; + + // Bei beendetem Spiel: vollständiges Grid ohne Fog + let maskedGrid, maskedTerrain; + if (game.status === 'finished') { + maskedGrid = grid; + maskedTerrain = terrain; + } else { + const visible = computeFog(grid, requesterId, myScouts); + maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0))); + maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1)); + } + + return { + ...game, + grid: JSON.stringify(maskedGrid), + terrain: JSON.stringify(maskedTerrain), + myScore, + oppScore: oppSc, + myHead, + oppHead, + myScouts, + }; +} + +const uid = req => req.user.id; + +// ── Alle Spiele ─────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + const games = db.prepare(` + SELECT g.*, u1.username as owner_name, u2.username as opp_name + FROM geo_games g + JOIN users u1 ON u1.id = g.owner_id + JOIN users u2 ON u2.id = g.opponent_id + WHERE (g.owner_id=? OR g.opponent_id=?) + AND NOT (g.status='finished' AND g.move_count < 2) + ORDER BY g.updated_at DESC + `).all(me, me); + res.json(games); +}); + +// ── Meine Züge + ungesehene Spielenden ────────────────────────────────────── +router.get('/my-turns', authenticate, (req, res) => { + const me = uid(req); + // Aktive Spiele wo ich dran bin + const { count: turns } = db.prepare(` + SELECT COUNT(*) as count FROM geo_games + WHERE status='active' AND current_turn=? + `).get(me); + // Beendete Spiele wo ich beteiligt bin aber noch nicht gesehen habe + const games = db.prepare(` + SELECT id, seen_by FROM geo_games + WHERE status='finished' AND move_count >= 2 + AND (owner_id=? OR opponent_id=?) + `).all(me, me); + const unseen = games.filter(g => { + const seen = g.seen_by ? JSON.parse(g.seen_by) : []; + return !seen.includes(me); + }).length; + res.json({ count: turns + unseen }); +}); + +// ── Topliste ────────────────────────────────────────────────────────────────── +router.get('/leaderboard', authenticate, (req, res) => { + const rows = db.prepare(` + SELECT u.username, COUNT(*) as wins + FROM geo_games g + JOIN users u ON u.id = g.winner_id + WHERE g.status='finished' AND g.winner_id IS NOT NULL AND g.move_count >= 2 + GROUP BY g.winner_id ORDER BY wins DESC LIMIT 20 + `).all(); + res.json(rows); +}); + +// ── User ────────────────────────────────────────────────────────────────────── +router.get('/users', authenticate, (req, res) => { + const me = uid(req); + const isAdmin = req.user?.role === 'admin'; + const users = isAdmin + ? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me) + : db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me); + res.json(users); +}); + +// ── Einzelnes Spiel ─────────────────────────────────────────────────────────── +router.get('/:id', authenticate, (req, res) => { + const me = uid(req); + const game = getGameForUser(req.params.id, me); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); + res.json(game); +}); + +// ── Neues Spiel ─────────────────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const me = uid(req); + const { opponent_id } = req.body; + if (!opponent_id) return res.status(400).json({ error: 'Gegner fehlt' }); + if (Number(opponent_id) === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' }); + + const opp = db.prepare('SELECT id, username FROM users WHERE id=?').get(opponent_id); + if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + + const { grid, ownerHead, oppHead } = createGrid(me, Number(opponent_id)); + const terrain = generateTerrain(ownerHead, oppHead); + + const result = db.prepare(` + INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count, owner_head, opp_head) + VALUES (?, ?, ?, ?, ?, 0, ?, ?) + `).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me, ownerHead, oppHead); + + const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; + sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); + res.json({ id: result.lastInsertRowid }); +}); + +// ── Zug machen ──────────────────────────────────────────────────────────────── +router.post('/:id/move', authenticate, (req, res) => { + const me = uid(req); + const { row, col } = req.body; + if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' }); + + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); + if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' }); + if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' }); + + const grid = JSON.parse(game.grid); + const terrain = JSON.parse(game.terrain || '[]'); + + if (row<0||row>=GRID||col<0||col>=GRID) return res.status(400).json({ error: 'Ungültige Position' }); + if (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' }); + if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' }); + // Schlangen-Regel: Zug muss an den eigenen Kopf angrenzen + const myHeadStr = game.owner_id === me ? game.owner_head : game.opp_head; + const [myHR, myHC] = myHeadStr ? myHeadStr.split(',').map(Number) : [null, null]; + if (myHR !== null && !isAdjacentToHead(row, col, myHR, myHC)) + return res.status(400).json({ error: 'Nur an deinen Kopf anbauen!' }); + if (myHR === null && !isAdjacent(grid, row, col, me)) + return res.status(400).json({ error: 'Nicht angrenzend' }); + + const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; + const newMoves = (game.move_count || 0) + 1; + const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; + const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand'; + + // Zug ausführen + grid[row][col] = me; + + // Neuen Kopf setzen + let newOwnerHead = game.owner_head; + let newOppHead = game.opp_head; + if (game.owner_id === me) newOwnerHead = `${row},${col}`; + else newOppHead = `${row},${col}`; + + let lastEvent = null; + let mineHit = false; + let blastCells = []; + let destroyedOwn = 0; + let destroyedOpp = 0; + + // ── Mine betreten: Explosion der 8 Nachbarn ────────────────────────────── + if (terrain.length && terrain[row][col] === T_MINE) { + mineHit = true; + const explosion = explodeMine(grid, terrain, row, col, me, opponent); + blastCells = explosion.blastCells; + destroyedOwn = explosion.destroyedByOwner; + destroyedOpp = explosion.destroyedByOpp; + lastEvent = JSON.stringify({ type:'mine', player:me, row, col, blastCells, destroyedOwn, destroyedOpp }); + + // Kopf zurücksetzen: Mine-Feld gehört dem Spieler, aber Kopf soll auf das letzte + // noch existierende eigene Feld vor dem Mine-Zug zeigen. + // Der alte Kopf (myHR, myHC) wird durch explodeMine nicht zerstört (Explosion trifft Nachbarn), + // also ist er noch vorhanden — Kopf bleibt beim alten Kopf (vor dem Mine-Zug). + // Falls kein alter Kopf bekannt: suche nächstes eigenes Feld neben der Mine. + // Kopf nach Explosion: erstes eigenes Feld das AUSSERHALB des 3x3-Radius der Mine liegt + // (Mine + 8 Nachbarn sind alle Felsen — der Kopf muss weiter weg sein) + let safeHead = null; + // Alle eigenen Felder sammeln und nach Entfernung zur Mine sortieren + const ownCells = []; + for (let r=0;r1 || Math.abs(c-col)>1; + if (outside) ownCells.push([r, c, Math.abs(r-row)+Math.abs(c-col)]); + } + } + // Nächstes eigenes Feld außerhalb des Radius (Manhattan-Distanz minimal) + if (ownCells.length > 0) { + ownCells.sort((a,b) => a[2]-b[2]); + safeHead = `${ownCells[0][0]},${ownCells[0][1]}`; + } + if (safeHead) { + if (game.owner_id === me) newOwnerHead = safeHead; + else newOppHead = safeHead; + } + } + + // ── Mine ins Sichtfeld gekommen: Explosion prüfen ──────────────────────── + // Alle Minen die jetzt im Sichtfeld von BEIDEN Spielern liegen und noch nicht explodiert sind + // werden gezündet sobald ein Spieler sie sieht (d.h. sie grenzen an sein Gebiet) + // Dieses Feature: Mine explodiert wenn sie ins Sichtfeld kommt (nicht wenn betreten) + // → wird beim Betreten gehandhabt (oben), kein extra Trigger nötig + + // Nächster Zug + let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren + let status = 'active'; + let winnerId = null; + + // Effektiver Kopf nach Zug (bei Mine: zurückgesetzter Kopf, sonst neue Position) + const oppHeadStr = game.owner_id === me ? game.opp_head : game.owner_head; + const [oppHR, oppHC] = oppHeadStr ? oppHeadStr.split(',').map(Number) : [null, null]; + const effectiveMyHeadStr = game.owner_id === me ? newOwnerHead : newOppHead; + const [effectiveMyHR, effectiveMyHC] = effectiveMyHeadStr + ? effectiveMyHeadStr.split(',').map(Number) : [row, col]; + + // Spielende: niemand kann mehr ziehen + if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, effectiveMyHR, effectiveMyHC)) { + status = 'finished'; + } else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) { + nextTurn = me; + } + + // Schnellsieg: jemand hat doppelt so viele Punkte wie der Gegner (mind. 10 Züge gespielt) + if (status === 'active' && newMoves >= 10) { + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + if (ms >= os * 2 && os > 0) { status = 'finished'; } + else if (os >= ms * 2 && ms > 0) { status = 'finished'; } + } + + if (status === 'finished') { + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + winnerId = ms > os ? me : (os > ms ? opponent : null); + } + + // last_event: nach jedem Zug das vorherige Event clearen (war vom letzten Zug) + // Nur das neue Event (falls Mine) bleibt stehen + const finalEvent = lastEvent; // null wenn kein Mine-Treffer + + db.prepare(` + UPDATE geo_games SET grid=?, terrain=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?, + owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=? + `).run(JSON.stringify(grid), JSON.stringify(terrain), nextTurn, status, winnerId, newMoves, finalEvent, newOwnerHead, newOppHead, game.id); + + // Pushover + if (status === 'finished') { + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : ''; + if (winnerId === me) { + sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen!${reason} ${ms} vs ${os} Punkte.`); + sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${ms} vs ${os} Punkte. 🎉`); + } else if (winnerId === opponent) { + sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`); + sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${os} vs ${ms} Punkte. 🎉`); + } else { + sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`); + sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`); + } + } else { + if (mineHit) { + sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`); + sendPush(opponent, '⬡ Hex Wars — 💥 Explosion!', `${myName} hat eine Mine getroffen! ${destroyedOpp} deiner Felder wurden zerstört. Du bist dran!`); + } else if (nextTurn === opponent) { + sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`); + } + } + + res.json(getGameForUser(game.id, me)); +}); + +// ── Spiel als gesehen markieren ────────────────────────────────────────────── +router.post('/:id/seen', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); + const seen = game.seen_by ? JSON.parse(game.seen_by) : []; + if (!seen.includes(me)) { + seen.push(me); + db.prepare('UPDATE geo_games SET seen_by=? WHERE id=?').run(JSON.stringify(seen), game.id); + } + res.json({ ok: true }); +}); + +// ── Scout: 3x3 Bereich aufdecken (kostet Zug) ─────────────────────────────── +router.post('/:id/scout', authenticate, (req, res) => { + const me = uid(req); + const { row, col } = req.body; + if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' }); + + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); + if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' }); + if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' }); + + const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; + const newMoves = (game.move_count || 0) + 1; + + // Scouts des Spielers laden und neuen hinzufügen + const isOwner = game.owner_id === me; + const scoutsKey = isOwner ? 'owner_scouts' : 'opp_scouts'; + const existingRaw = game[scoutsKey]; + const existing = existingRaw ? JSON.parse(existingRaw) : []; + existing.push([row, col]); + const newScoutsStr = JSON.stringify(existing); + + // Zug weitergeben (Scout kostet Zug) + const grid = JSON.parse(game.grid); + const terrain = JSON.parse(game.terrain || '[]'); + let status = 'active', winnerId = null; + + // Schnellsieg-Check + if (newMoves >= 10 && terrain.length) { + const ms = calcScore(grid, terrain, me); + const os = calcScore(grid, terrain, opponent); + if (ms >= os*2 && os > 0) status = 'finished'; + else if (os >= ms*2 && ms > 0) status = 'finished'; + if (status === 'finished') { + winnerId = ms > os ? me : (os > ms ? opponent : null); + } + } + + const updateFields = isOwner + ? 'owner_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL' + : 'opp_scouts=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=NULL'; + + db.prepare(`UPDATE geo_games SET ${updateFields}, updated_at=datetime('now','localtime') WHERE id=?`) + .run(newScoutsStr, opponent, status, winnerId, newMoves, game.id); + + const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; + sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gekundschaftet — jetzt bist du dran!`); + + res.json(getGameForUser(game.id, me)); +}); + +// ── Aufgeben ────────────────────────────────────────────────────────────────── +router.post('/:id/resign', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); + if (game.status !== 'active') return res.status(400).json({ error: 'Spiel bereits beendet' }); + + const winner = game.owner_id === me ? game.opponent_id : game.owner_id; + // move_count auf min. 2 setzen damit Spiel in Liste + Leaderboard erscheint + // seen_by: aufgebender Spieler hat es gesehen, Gewinner noch nicht + const newMoveCount = Math.max(game.move_count || 0, 2); + const seenBy = JSON.stringify([me]); // nur der Aufgebende hat es "gesehen" + db.prepare(` + UPDATE geo_games SET status='finished', winner_id=?, move_count=?, seen_by=?, + updated_at=datetime('now','localtime') WHERE id=? + `).run(winner, newMoveCount, seenBy, game.id); + const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; + sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`); + res.json({ ok: true }); +}); + +// ── Löschen (Admin) ─────────────────────────────────────────────────────────── +router.delete('/:id', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + if (game.status !== 'finished') return res.status(400).json({ error: 'Nur beendete Spiele können gelöscht werden' }); + db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/kalkulator3d/routes.js b/backend/src/tools/kalkulator3d/routes.js new file mode 100644 index 0000000..773f13e --- /dev/null +++ b/backend/src/tools/kalkulator3d/routes.js @@ -0,0 +1,191 @@ +// Tool: 3D-Druck-Kalkulator +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +router.get('/', authenticate, (req, res) => { + const uid = req.user.id; + const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid); + + const baseFolder = db.prepare( + "SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL" + ).get(uid); + let filesMap = {}; + if (baseFolder) { + const rows = db.prepare(` + SELECT f.name, COUNT(fi.id) AS cnt FROM folders f + LEFT JOIN files fi ON fi.folder_id = f.id + WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id + `).all(baseFolder.id, uid); + for (const r of rows) filesMap[r.name] = r.cnt > 0; + } + + // Geteilt mit mir + const shared = db.prepare(` + SELECT c.*, u.username as owner_name, 1 as is_shared + FROM calculations c + JOIN calculation_shares cs ON cs.calc_id = c.id + JOIN users u ON u.id = c.user_id + WHERE cs.shared_with = ? + ORDER BY c.created_at DESC + `).all(uid); + + // Geteilt von mir + const sharedByMe = db.prepare(` + SELECT c.*, u.username as shared_with_name, cs.created_at as shared_at + FROM calculations c + JOIN calculation_shares cs ON cs.calc_id = c.id + JOIN users u ON u.id = cs.shared_with + WHERE c.user_id = ? + ORDER BY c.name ASC + `).all(uid); + + // has_files auch für shared items prüfen (anhand owner's Ordner) + const sharedWithFiles = shared.map(i => { + const ownerBase = db.prepare( + "SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL" + ).get(i.user_id); + let has_files = false; + if (ownerBase) { + const sub = db.prepare( + 'SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?' + ).get(i.user_id, i.name, ownerBase.id); + if (sub) { + const cnt = db.prepare('SELECT COUNT(*) AS c FROM files WHERE folder_id=?').get(sub.id); + has_files = cnt.c > 0; + } + } + return { ...i, has_files }; + }); + + res.json({ + own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })), + shared: sharedWithFiles, + sharedByMe: sharedByMe.map(i => ({ ...i, has_files: !!filesMap[i.name] })), + }); +}); + +// Dateien eines Modells abrufen (für Owner + shared users) +router.get('/:id/files', authenticate, (req, res) => { + const uid = req.user.id; + const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id); + if (!calc) return res.status(404).json({ error: 'Nicht gefunden' }); + const isOwner = calc.user_id === uid; + const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid); + if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' }); + + const baseFolder = db.prepare( + "SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL" + ).get(calc.user_id); + if (!baseFolder) return res.json([]); + const sub = db.prepare('SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?') + .get(calc.user_id, calc.name, baseFolder.id); + if (!sub) return res.json([]); + res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY created_at ASC').all(sub.id)); +}); + +// Einzelne Datei herunterladen (Owner + shared users) +router.get('/:id/files/:fileId/download', authenticate, (req, res) => { + const uid = req.user.id; + const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id); + if (!calc) return res.status(404).json({ error: 'Nicht gefunden' }); + const isOwner = calc.user_id === uid; + const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid); + if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' }); + + const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.fileId); + if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' }); + + const path = require('path'); + const fs = require('fs'); + const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + const fp = path.join(UPLOAD_DIR, file.filename); + if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' }); + res.download(fp, file.originalname); +}); + + +// Share-Management +router.get('/:id/shares', authenticate, (req, res) => { + const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!calc) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(db.prepare(` + SELECT u.id, u.username, cs.created_at as shared_at + FROM calculation_shares cs JOIN users u ON u.id = cs.shared_with + WHERE cs.calc_id = ? + `).all(calc.id)); +}); + +router.post('/:id/share', authenticate, (req, res) => { + const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!calc) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + db.prepare("INSERT OR IGNORE INTO calculation_shares (calc_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))") + .run(calc.id, req.user.id, target.id); + res.json({ ok: true }); +}); + +router.delete('/:id/share/:userId', authenticate, (req, res) => { + const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!calc) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM calculation_shares WHERE calc_id=? AND shared_with=?').run(calc.id, req.params.userId); + res.json({ ok: true }); +}); + +router.post('/', authenticate, (req, res) => { + const { name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag, + image = null, bemerkung = '' } = req.body; + + if (!name || gramm == null || stunden == null) + return res.status(400).json({ error: 'Name, Gramm und Stunden erforderlich' }); + + const r = db.prepare(`INSERT INTO calculations + (user_id,name,gramm,stunden,farben,materialpreis_pro_gramm,stromverbrauch_kw, + strompreis_pro_kwh,druckerpreis,gesamtdruckstunden,verschleiss_pro_stunde, + preis_freundschaft,preis_normal,preis_auftrag,image,bemerkung) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + .run(req.user.id, name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung); + + res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/:id', authenticate, (req, res) => { + const ex = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!ex) return res.status(404).json({ error: 'Nicht gefunden' }); + + const { name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag, + image = ex.image, bemerkung = ex.bemerkung } = req.body; + + db.prepare(`UPDATE calculations SET + name=?,gramm=?,stunden=?,farben=?,materialpreis_pro_gramm=?,stromverbrauch_kw=?, + strompreis_pro_kwh=?,druckerpreis=?,gesamtdruckstunden=?,verschleiss_pro_stunde=?, + preis_freundschaft=?,preis_normal=?,preis_auftrag=?,image=?,bemerkung=?, + updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?`) + .run(name, gramm, stunden, farben, + materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh, + druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde, + preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung, + req.params.id, req.user.id); + + res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM calculations WHERE id=? AND user_id=?').run(req.params.id, req.user.id); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/kanban/routes.js b/backend/src/tools/kanban/routes.js new file mode 100644 index 0000000..5603ebd --- /dev/null +++ b/backend/src/tools/kanban/routes.js @@ -0,0 +1,155 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS kanban_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4ecdc4', + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + CREATE TABLE IF NOT EXISTS kanban_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + column_id INTEGER NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'none', + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + `); + // Nachrüsten falls Tabelle schon existiert aber color fehlt + const cols = db.pragma('table_info(kanban_columns)').map(c => c.name); + if (!cols.includes('color')) { + db.exec("ALTER TABLE kanban_columns ADD COLUMN color TEXT NOT NULL DEFAULT '#4ecdc4'"); + } +})(); + +const uid = req => req.user.id; + +// ── Alle Spalten + Karten laden ─────────────────────────────────────────────── +router.get('/board', authenticate, (req, res) => { + const me = uid(req); + const columns = db.prepare( + 'SELECT * FROM kanban_columns WHERE user_id=? ORDER BY position ASC, id ASC' + ).all(me); + const cards = db.prepare( + 'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC' + ).all(me); + const colMap = {}; + for (const c of columns) { c.cards = []; colMap[c.id] = c; } + for (const card of cards) { + if (colMap[card.column_id]) colMap[card.column_id].cards.push(card); + } + res.json({ columns }); +}); + +// ── Spalte erstellen ────────────────────────────────────────────────────────── +router.post('/columns', authenticate, (req, res) => { + const { title, color = '#4ecdc4' } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' }); + const me = uid(req); + const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me); + const pos = (maxPos?.m ?? -1) + 1; + const r = db.prepare( + "INSERT INTO kanban_columns (user_id,title,color,position) VALUES (?,?,?,?)" + ).run(me, title.trim(), color, pos); + res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid)); +}); + +// ── Spalte aktualisieren (Titel + Farbe) ───────────────────────────────────── +// Feste Route vor :param (Lesson Learned) +router.post('/columns/reorder', authenticate, (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' }); + const me = uid(req); + const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?'); + db.transaction(() => { for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); })(); + res.json({ ok: true }); +}); + +router.patch('/columns/:id', authenticate, (req, res) => { + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + const title = req.body.title !== undefined ? req.body.title.trim() : col.title; + const color = req.body.color !== undefined ? req.body.color : col.color; + if (!title) return res.status(400).json({ error: 'Titel fehlt' }); + db.prepare('UPDATE kanban_columns SET title=?, color=? WHERE id=?').run(title, color, col.id); + res.json({ ok: true }); +}); + +router.delete('/columns/:id', authenticate, (req, res) => { + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + db.prepare('DELETE FROM kanban_columns WHERE id=?').run(col.id); + res.json({ ok: true }); +}); + +// ── Karten ──────────────────────────────────────────────────────────────────── +router.post('/cards', authenticate, (req, res) => { + const { column_id, title, description = '', priority = 'none' } = req.body; + if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' }); + const me = uid(req); + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(column_id, me); + if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' }); + const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_cards WHERE column_id=?').get(column_id); + const pos = (maxPos?.m ?? -1) + 1; + const r = db.prepare( + "INSERT INTO kanban_cards (column_id,user_id,title,description,priority,position) VALUES (?,?,?,?,?,?)" + ).run(column_id, me, title.trim(), description.trim(), priority, pos); + res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid)); +}); + +router.patch('/cards/:id', authenticate, (req, res) => { + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + const title = req.body.title !== undefined ? req.body.title.trim() : card.title; + const description = req.body.description !== undefined ? req.body.description.trim() : card.description; + const priority = req.body.priority !== undefined ? req.body.priority : card.priority; + if (!title) return res.status(400).json({ error: 'Titel darf nicht leer sein' }); + db.prepare( + "UPDATE kanban_cards SET title=?,description=?,priority=?,updated_at=datetime('now','localtime') WHERE id=?" + ).run(title, description, priority, card.id); + res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id)); +}); + +router.delete('/cards/:id', authenticate, (req, res) => { + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + db.prepare('DELETE FROM kanban_cards WHERE id=?').run(card.id); + res.json({ ok: true }); +}); + +router.post('/cards/:id/move', authenticate, (req, res) => { + const { column_id, position } = req.body; + const me = uid(req); + const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me); + if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' }); + const targetColId = column_id !== undefined ? column_id : card.column_id; + const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me); + if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' }); + const siblings = db.prepare( + 'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC' + ).all(targetColId, card.id).map(r => r.id); + const targetPos = position !== undefined + ? Math.max(0, Math.min(position, siblings.length)) + : siblings.length; + siblings.splice(targetPos, 0, card.id); + const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?'); + db.transaction(() => { for (let i = 0; i < siblings.length; i++) upd.run(targetColId, i, siblings[i]); })(); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js new file mode 100644 index 0000000..7b516d4 --- /dev/null +++ b/backend/src/tools/koepi/routes.js @@ -0,0 +1,954 @@ +const express = require('express'); +const https = require('https'); +const crypto = require('crypto'); +const db = require('../../db'); +const mqttClient = require('../../mqtt'); +const { logPush } = require('../../pushLog'); +const { logPublicAccess } = require('../../publicAccessLog'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── Cache ───────────────────────────────────────────────────────────────────── +const cache = {}; +function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; } +function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; } + +// ── marktguru Cookie (Standort Duisburg 47259) ──────────────────────────────── +const MG_SETTINGS = encodeURIComponent(JSON.stringify({ + location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] }, + locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35, +})); + +// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─ +const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut','lidl','aldi','hornbach']; +function isTargetRetailer(name) { + if (!name) return false; + const n = name.toLowerCase(); + return TARGET_RETAILERS.some(r => n.includes(r)); +} + +// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.", +// Kommas/Leerzeichen/Punkte egal) +function normalizeAddress(s) { + return (s || '') + .toLowerCase() + .replace(/straße|strasse/g, 'str') + .replace(/[^a-zäöüß0-9]/g, ''); +} +// Adressen mit mehreren kommagetrennten Hausnummern (z.B. "Straße 173,173a") +// in einzelne vollständige Varianten zerlegen ("Straße 173" / "Straße 173a"), +// damit ein Treffer gegen nur eine der beiden Hausnummern trotzdem zählt +function expandAddressVariants(addr) { + const m = (addr || '').match(/^(.*?)(\d.*)$/); + if (!m) return [addr]; + const streetPart = m[1].trim(); + const numbers = m[2].split(',').map(s => s.trim()).filter(Boolean); + if (numbers.length <= 1) return [addr]; + return numbers.map(n => `${streetPart} ${n}`); +} +function addressesMatch(a, b) { + for (const va of expandAddressVariants(a)) { + for (const vb of expandAddressVariants(b)) { + const na = normalizeAddress(va), nb = normalizeAddress(vb); + if (na && nb && (na.includes(nb) || nb.includes(na))) return true; + } + } + return false; +} + +// Die gewünschten Filialen. Zwei Wege, wie eine Filiale hier eingetragen wird: +// - leafletId bekannt (Prospekt-Link bestätigt): wird direkt aufgelöst. +// - nur addressMatch bekannt: wird automatisch unter allen AKTUELL laufenden +// Prospekten dieser Kette in der Umgebung gesucht — die closestStore-Adresse +// wird gegen die angegebene Zieladresse abgeglichen, kein Prospekt-Link nötig. +const LOCAL_STORE_SEED_LEAFLETS = [ + { retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19 + { retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21 + { retailer: 'trinkgut', leafletId: 5634593 }, // Keniastraße 39 + { retailer: 'REWE', leafletId: 5616164 }, // Fischerstr. 110-112 + { retailer: 'Kaufland', leafletId: 5628827 }, // Auf der Höhe 20 + { retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10 + { retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361 +]; +const LOCAL_STORE_SEED_BY_ADDRESS = [ + { retailer: 'Lidl', matchRetailerKey: 'lidl', addressMatch: 'Mündelheimer Str. 184' }, + { retailer: 'ALDI Süd', matchRetailerKey: 'aldi', addressMatch: 'Mündelheimer Straße 173' }, + { retailer: 'HORNBACH', matchRetailerKey: 'hornbach', addressMatch: 'Düsseldorfer Straße 400' }, +]; + +function isKnownLocalStore(storeId) { + if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken + return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId); +} + +// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Filiale — +// zusätzliches Sicherheitsnetz, greift aber durch die ID-Whitelist ohnehin kaum +// noch (REWE Dortmund taucht dort schlicht nicht auf) +const EXCLUDED_RETAILER_NAMES = ['rewe dortmund']; +function isExcludedRetailer(retailer) { + const n = (retailer || '').toLowerCase(); + return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x)); +} + +// ── Angebote von marktguru ──────────────────────────────────────────────────── +// Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser +// nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal +// ungültig werden), fällt der Code automatisch auf das alte Puppeteer/HTML- +// Scraping zurück, damit KöPi nicht komplett ausfällt. +const MARKTGURU_API_URL = 'https://api.marktguru.de/api/v1/offers/search'; +const MARKTGURU_CLIENT_KEY = 'WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90='; +const MARKTGURU_API_KEY = '8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE='; +const MG_ZIP = '47259'; +const MG_QUERY = 'könig pilsener'; + +function formatIsoDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = n => String(n).padStart(2, '0'); + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`; +} + +// Duisburg 47259 Zentrum — für den closestStore-Abgleich im leaflets-Detail-Call. +// Innerhalb der zipCode-Filterung spielt der genaue Punkt kaum eine Rolle. +const MG_LAT = '51.3543213'; +const MG_LON = '6.7151937'; + +const mgHeaders = { + 'x-clientkey': MARKTGURU_CLIENT_KEY, + 'x-apikey': MARKTGURU_API_KEY, + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +}; + +// Holt für eine PLZ alle laufenden Prospekt-Kampagnen (leafletFlights) und baut +// eine Zuordnung leafletFlightId -> mainLeafletId, die wir brauchen, um darüber +// später das leaflet-Detail (inkl. closestStore) abzurufen. +async function fetchLeafletFlightToLeafletMap() { + const url = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`; + const res = await fetch(url, { headers: mgHeaders }); + if (!res.ok) throw new Error(`leafletflights HTTP ${res.status}`); + const json = await res.json(); + const map = new Map(); + for (const f of (json.results || [])) { + if (f.id && f.mainLeafletId) map.set(f.id, f.mainLeafletId); + } + console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen geladen (von ${json.totalResults} gesamt)`); + return map; +} + +// Holt für ein einzelnes Leaflet den kompletten Datensatz von marktguru: +// closestStore (id/address/distanceInMeters — Grundlage der Filial-Whitelist), +// name (enthält oft "(KW29 Do-Sa)" o.ä.) und children (Liste aller Angebote +// mit ihrem pageIndex, für den seiten-genauen Direktlink). +async function fetchLeafletDetail(leafletId, cache) { + if (cache.has(leafletId)) return cache.get(leafletId); + const empty = { id: leafletId, name: '', closestStore: { id: null, address: '', distanceMeters: null }, children: [] }; + try { + const url = `https://api.marktguru.de/api/v1/leaflets/${leafletId}?as=mobiledetailed&latitude=${MG_LAT}&longitude=${MG_LON}&zipCode=${MG_ZIP}`; + const res = await fetch(url, { headers: mgHeaders }); + if (!res.ok) { cache.set(leafletId, empty); return empty; } + const json = await res.json(); + const store = json.closestStore; + const result = { + id: json.id ?? leafletId, + name: json.name || '', + closestStore: store + ? { id: store.id ?? null, address: store.address || '', distanceMeters: store.distanceInMeters ?? null } + : { id: null, address: '', distanceMeters: null }, + children: json.children || [], + }; + console.log(`🍺 KöPi: Leaflet ${leafletId} ("${result.name}") → closestStore: ${store ? `id=${store.id} "${store.address}" (${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`); + cache.set(leafletId, result); + return result; + } catch (e) { + console.log(`🍺 KöPi: Leaflet-Detail-Abruf für ${leafletId} fehlgeschlagen: ${e.message}`); + cache.set(leafletId, empty); + return empty; + } +} + +// Extrahiert eine "(KW29 Do-Sa)"-artige Angabe aus dem Leaflet-Namen. Manche +// Ketten (z.B. ALDI) schreiben die KW-Angabe ohne umschließende Klammern +// ("... KW32 ...") — die wird dann selbst eingeklammert, damit die Anzeige +// überall einheitlich aussieht. +function extractWeekInfo(name) { + // Fängt nur "KW29" bzw. "KW 29" plus optional einen kurzen Wochentag-Bereich + // wie "Do-Sa" direkt danach ein — ignoriert bewusst alles andere drumherum + // (z.B. technischen Datensatz-Text wie "HHZ Dataset 2"), egal ob das Ganze + // in Klammern steht oder nicht. Klammert das Ergebnis immer selbst neu ein, + // damit die Anzeige überall einheitlich aussieht. + const m = (name || '').match(/KW\s*\d+(?:\s*[A-Za-zÄÖÜäöüß]{2,3}-[A-Za-zÄÖÜäöüß]{2,3})?/i); + return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : ''; +} + +// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel: +// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als +// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort +// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran). +const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi]; +function cleanTitle(name) { + let t = name || ''; + for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, ''); + return t.replace(/\s{2,}/g, ' ').trim(); +} + +// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert: +// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen +function getISOWeekNumber(dateStr) { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + 4 - (d.getDay() || 7)); + const yearStart = new Date(d.getFullYear(), 0, 1); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +} +function weekInfoWithFallback(name, validFromIso) { + const found = extractWeekInfo(name); + if (found) return found; + const week = validFromIso ? getISOWeekNumber(validFromIso) : null; + return week ? `(KW${week})` : ''; +} + +// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig +// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim +// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann +// über den Admin-Button jederzeit erneut angestoßen werden, falls sich mal ein +// Prospekt-Link ändert oder eine Filiale ergänzt werden soll. +async function resolveLocalStores() { + const cache = new Map(); + let resolved = 0; + + // 1) Direkt bekannte Leaflet-IDs (per Prospekt-Link bestätigt) + for (const seed of LOCAL_STORE_SEED_LEAFLETS) { + const detail = await fetchLeafletDetail(seed.leafletId, cache); + const store = detail.closestStore; + if (store.id != null) { + db.prepare(` + INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(store.id, seed.retailer, store.address); + console.log(`🍺 KöPi: Filiale aufgelöst — ${seed.retailer} → Store-ID ${store.id} ("${store.address}")`); + resolved++; + } else { + console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`); + } + } + + // 2) Nur Adresse bekannt — unter allen aktuell laufenden Prospekten dieser + // Kette in der Umgebung suchen, closestStore-Adresse gegen Zieladresse prüfen + if (LOCAL_STORE_SEED_BY_ADDRESS.length) { + try { + const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`; + const flightsRes = await fetch(flightsUrl, { headers: mgHeaders }); + const flightsJson = flightsRes.ok ? await flightsRes.json() : { results: [] }; + + for (const target of LOCAL_STORE_SEED_BY_ADDRESS) { + const candidates = (flightsJson.results || []).filter(f => + (f.advertiser?.name || '').toLowerCase().includes(target.matchRetailerKey) + ); + console.log(`🍺 KöPi: Suche "${target.retailer}" per Adresse — ${candidates.length} laufende Prospekt(e) dieser Kette gefunden`); + let found = null; + for (const c of candidates) { + if (!c.mainLeafletId) continue; + const detail = await fetchLeafletDetail(c.mainLeafletId, cache); + const store = detail.closestStore; + if (store.id != null && addressesMatch(store.address, target.addressMatch)) { + found = store; + break; + } + } + if (found) { + db.prepare(` + INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(found.id, target.retailer, found.address); + console.log(`🍺 KöPi: Filiale per Adresse gefunden — ${target.retailer} → Store-ID ${found.id} ("${found.address}")`); + resolved++; + } else { + console.log(`🍺 KöPi: Filiale per Adresse NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}") — evtl. gerade kein laufender Prospekt dieser Kette in der Gegend`); + } + } + } catch (e) { + console.log('🍺 KöPi: Adress-basierte Filial-Suche fehlgeschlagen:', e.message); + } + } + + return resolved; +} + +async function fetchOffersViaApi() { + const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`; + const res = await fetch(url, { headers: mgHeaders }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`); + } + const json = await res.json(); + console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length); + + const rawOffers = json.results || []; + + // Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore) + let flightToLeaflet = new Map(); + try { + flightToLeaflet = await fetchLeafletFlightToLeafletMap(); + } catch (e) { + console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message); + } + const leafletCache = new Map(); + const detailByOfferId = new Map(); + for (const r of rawOffers) { + const leafletId = flightToLeaflet.get(r.leafletFlightId); + if (leafletId) { + const detail = await fetchLeafletDetail(leafletId, leafletCache); + detailByOfferId.set(r.id, detail); + } + } + + return rawOffers.map(r => { + const advertiser = r.advertisers?.[0] || {}; + const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price); + const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice); + const detail = detailByOfferId.get(r.id); + const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null }; + const validity0 = r.validityDates?.[0] || {}; + const weekInfo = weekInfoWithFallback(detail?.name, validity0.from); + const baseDateRange = validity0.from && validity0.to + ? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}` + : formatIsoDate(validity0.to); + // Seiten-genauer Direktlink zum Angebot innerhalb des Prospekts — wird bei + // jedem Scrape frisch aus den aktuellen API-Daten ermittelt (children[].id + // == "offers/{id}"), funktioniert also automatisch weiter bei neuen + // Prospekten, ohne dass irgendwas hartkodiert werden muss. + const child = detail?.children?.find(c => c.id === `offers/${r.id}`); + const leafletUrl = detail + ? `https://www.marktguru.de/leaflets/${detail.id}${child ? `/page/${child.pageIndex}` : ''}` + : null; + const knownStore = storeInfo.id != null + ? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(storeInfo.id) + : null; + const retailerLabel = knownStore?.retailer || advertiser.name || ''; + return { + name: r.description || r.title || advertiser.name || '', + brand: r.brand?.name || '', + price: Number.isFinite(priceNum) ? `€ ${priceNum.toFixed(2).replace('.', ',')}` : (r.price || ''), + oldPrice: Number.isFinite(oldPriceNum) ? `€ ${oldPriceNum.toFixed(2).replace('.', ',')}` : (r.oldPrice || ''), + retailer: retailerLabel, + dateRange: weekInfo ? `${baseDateRange} ${weekInfo}` : baseDateRange, + weekInfo, + validity: '', + description: r.description || '', + badge: '', + // Adresse kommt jetzt direkt von marktguru (closestStore.address), nicht mehr fest hinterlegt + address: storeInfo.address || '', + realAddress: storeInfo.address || '', + storeId: storeInfo.id, + storeDistanceMeters: storeInfo.distanceMeters, + leafletFlightId: r.leafletFlightId || null, + offerId: r.id || null, + leafletUrl, + image: r.image?.url || r.images?.[0]?.url || null, + }; + }); +} + +async function fetchOffersViaPuppeteer() { + let puppeteer; + try { puppeteer = require('puppeteer-core'); } + catch(e) { throw new Error('puppeteer-core nicht installiert'); } + + const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium-browser', + args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'], + headless: true, + }); + + try { + const page = await browser.newPage(); + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); + await page.setViewport({ width:1280, height:800 }); + await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' }); + + await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', { + waitUntil: 'networkidle2', timeout: 30000, + }); + // Seite nach unten scrollen damit lazy-loaded Bilder geladen werden + await page.evaluate(async () => { + await new Promise(resolve => { + let totalHeight = 0; + const distance = 300; + const timer = setInterval(() => { + window.scrollBy(0, distance); + totalHeight += distance; + if (totalHeight >= document.body.scrollHeight) { + clearInterval(timer); + resolve(); + } + }, 200); + }); + }); + await new Promise(r => setTimeout(r, 3000)); + + const offers = await page.evaluate(() => { + // Exakte Selektoren basierend auf marktguru HTML-Struktur + const cards = document.querySelectorAll('li.offer-list-item'); + return Array.from(cards).map(card => { + const name = card.querySelector('h3')?.textContent?.trim() || ''; + const brand = card.querySelector('dd.brand a')?.textContent?.trim() || ''; + const price = card.querySelector('span.price')?.textContent?.trim() || ''; + const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || ''; + const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || ''; + const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || ''; + const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || ''; + const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` : + card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : ''; + const descEl = card.querySelector('.info div, .info'); + const description = descEl?.textContent?.trim() || ''; + const img = card.querySelector('img.offer-list-item-img'); + const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || ''; + const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null; + const allDds = Array.from(card.querySelectorAll('dd')); + const retailerIdx = allDds.findIndex(d => d.classList.contains('retailer-name')); + let realAddress = ''; + if (retailerIdx !== -1 && allDds[retailerIdx + 1]) { + const next = allDds[retailerIdx + 1]; + const isOtherField = next.classList.contains('valid') || next.classList.contains('time-left'); + if (!isOtherField) realAddress = next.textContent?.trim() || ''; + } + return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', realAddress, image: imgSrc }; + }); + }); + return offers; + } finally { + await browser.close(); + } +} + +// Prüft, ob "COUNT x VOL" (z.B. "20 x 0,5") in der Beschreibung vorkommt — +// robust gegen Leerzeichen um das "x", Komma/Punkt als Dezimaltrennzeichen, +// und verhindert Fehltreffer wie "0,5" innerhalb von "0,55" +function hasQty(desc, count, vol) { + const volPattern = vol.replace(',', '[,.]'); + const re = new RegExp(`${count}\\s*x\\s*${volPattern}(?!\\d)`, 'i'); + return re.test(desc); +} + +// Bild pro Angebot exakt nach Mengenangabe wählen (siehe Vorgabe): +// 20x0,5 -> kasten_050 | 20x0,33 -> kasten_steini | 24x0,33 -> kasten_033 +// 11x0,5 -> kasten_11er | 6x0,33 -> traeger | 24x0,5 -> palette_050 +// 20x0,5 UND 24x0,33 gleichzeitig -> kasten_033_05 | sonst -> koepi_platzhalter +function pickOfferImage(description) { + const d = (description || '').toLowerCase(); + const q2005 = hasQty(d, 20, '0,5'); + const q2433 = hasQty(d, 24, '0,33'); + if (q2005 && q2433) return '/koepi/kasten_033_05.png'; + if (hasQty(d, 20, '0,33')) return '/koepi/kasten_steini.png'; + if (q2433) return '/koepi/kasten_033.png'; + if (hasQty(d, 11, '0,5')) return '/koepi/kasten_11er.png'; + if (hasQty(d, 6, '0,33')) return '/koepi/traeger.png'; + if (hasQty(d, 24, '0,5')) return '/koepi/palette_050.png'; + if (q2005) return '/koepi/kasten_050.png'; + return '/koepi/koepi_platzhalter.png'; +} + +// Gemeinsame Weiterverarbeitung, egal ob die Rohdaten über die API oder per +// Puppeteer/HTML-Scraping ermittelt wurden +function processOffers(offers) { + // Lokale Bilder auswählen basierend auf Beschreibung + for (const offer of offers) { + offer.imageLocal = pickOfferImage(offer.description); + offer.image = null; // CDN nicht mehr nötig + } + + // Filtern auf Ziel-Händler + nur König Pilsener + const filtered = offers.filter(o => { + const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase(); + const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener'); + return hasKP && isTargetRetailer(o.retailer); + }); + + const excludedNow = filtered.filter(o => isExcludedRetailer(o.retailer)); + if (excludedNow.length) { + console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', ')); + } + + console.log('🍺 KöPi Filial-Whitelist-Diagnose:', filtered.map(o => + `${o.retailer} | Store-ID=${o.storeId ?? 'unbekannt'} | ${o.realAddress||'keine Adresse'} | bekannt=${isKnownLocalStore(o.storeId)}` + ).join('\n ')); + + const localOnly = filtered + .filter(o => !isExcludedRetailer(o.retailer)) + .filter(o => isKnownLocalStore(o.storeId)); + + for (const o of localOnly) { + o.retailerDisplay = o.retailer; + } + + // Deduplizieren: gleicher normalisierter Händler + gleicher Preis + gleiche + // Filiale → ein Eintrag + const merged = new Map(); + for (const o of localOnly) { + const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || ''; + const key = `${normalizedRetailer}|${o.price}|${o.storeId ?? ''}`; + if (merged.has(key)) { + const existing = merged.get(key); + if (o.description && (!existing.description || o.description.length < existing.description.length)) { + existing.description = o.description; + } + } else { + merged.set(key, { ...o }); + } + } + return [...merged.values()]; +} + +async function scrapeMarktguru(forceRefresh = false) { + const cacheKey = 'koepi:mg'; + if (!forceRefresh) { + const cached = cacheGet(cacheKey); + if (cached) return cached; + } + + // Filial-Whitelist beim allerersten Lauf automatisch auflösen (Tabelle leer) + const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n; + if (knownCount === 0) { + console.log('🍺 KöPi: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...'); + try { await resolveLocalStores(); } + catch (e) { console.error('🍺 KöPi: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); } + } + + let offers, source; + try { + offers = await fetchOffersViaApi(); + source = 'marktguru.de (API)'; + console.log(`🍺 KöPi: ${offers.length} Rohtreffer über die marktguru-API`); + } catch (apiErr) { + console.error('🍺 KöPi: API-Weg fehlgeschlagen, falle zurück auf Puppeteer:', apiErr.message); + try { + offers = await fetchOffersViaPuppeteer(); + source = 'marktguru.de (Puppeteer-Fallback)'; + console.log(`🍺 KöPi: ${offers.length} Rohtreffer per Puppeteer (Fallback)`); + } catch (ppErr) { + console.error('🍺 KöPi: auch Puppeteer-Fallback fehlgeschlagen:', ppErr.message); + return { offers: [], error: `API: ${apiErr.message} | Puppeteer: ${ppErr.message}`, scrapedAt: new Date().toISOString() }; + } + } + + const unique = processOffers(offers); + const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source }; + cacheSet(cacheKey, result); + return result; +} + +// ── Prospekte von marktguru (dieselbe Filial-Whitelist wie bei den Angeboten) ── +async function scrapeProspekte() { + const cacheKey = 'koepi:prospekte'; + const cached = cacheGet(cacheKey); + if (cached) return cached; + + const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n; + if (knownCount === 0) { + console.log('🍺 KöPi Prospekte: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...'); + try { await resolveLocalStores(); } + catch (e) { console.error('🍺 KöPi Prospekte: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); } + } + + const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`; + const flightsRes = await fetch(flightsUrl, { headers: mgHeaders }); + if (!flightsRes.ok) throw new Error(`leafletflights HTTP ${flightsRes.status}`); + const flightsJson = await flightsRes.json(); + console.log(`🍺 KöPi Prospekte: ${flightsJson.results?.length} von ${flightsJson.totalResults} Prospekt-Kampagnen geladen`); + + // Nur Kampagnen der gewünschten Ketten überhaupt im Detail auflösen (spart API-Calls) + const candidates = (flightsJson.results || []).filter(f => isTargetRetailer(f.advertiser?.name)); + + const leafletCache = new Map(); + const all = []; + for (const f of candidates) { + if (!f.mainLeafletId) continue; + const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache); + const store = detail.closestStore; + const weekInfo = weekInfoWithFallback(detail.name, f.validFrom); + // Store-spezifische Bezeichnung aus der Whitelist bevorzugen (z.B. "Netto + // Getränke-Discount") statt dem generischen advertiser.name von marktguru + // (liefert für diese Filiale fälschlich nur "Netto Marken-Discount") + const knownStore = store.id != null + ? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(store.id) + : null; + all.push({ + id: f.id, + publisher: knownStore?.retailer || f.advertiser?.name || '', + title: f.advertiser?.name || cleanTitle(detail.name) || '', + rawName: detail.name || '', // nur intern, für den Ausschluss-Filter unten + weekInfo, + street: store.address || '', + zip: MG_ZIP, + city: 'Duisburg', + storeId: store.id, + image: null, + validFrom: f.validFrom || null, + validTo: f.validTo || null, + pageCount: f.pageCount || null, + url: `https://www.marktguru.de/leaflets/${f.mainLeafletId}`, + isTarget: isKnownLocalStore(store.id) && store.id != null, + badges: [], + }); + } + + // Bier-Icon: mit den aktuellen König-Pilsener-Angeboten abgleichen, welche + // Filialen gerade wirklich König Pilsener im Angebot haben + try { + const offersResult = await scrapeMarktguru(false); + const storeIdsWithKoepi = new Set((offersResult.offers || []).map(o => o.storeId).filter(id => id != null)); + for (const b of all) b.hasKoenigPilsener = b.storeId != null && storeIdsWithKoepi.has(b.storeId); + } catch (e) { + console.log('🍺 KöPi Prospekte: Abgleich mit aktuellen Angeboten fehlgeschlagen (ignoriert):', e.message); + for (const b of all) b.hasKoenigPilsener = false; + } + + // Unerwünschte Prospekt-Typen ausblenden (Sondermagazine, Reise-Prospekte etc.) + // WICHTIG: gegen rawName (den echten Leaflet-Namen) prüfen, nicht gegen + // title/publisher — die zeigen nur noch den sauberen Kettennamen und würden + // "Sondermagazin"/"Bestellmagazin" im echten Namen sonst nie mehr finden. + const EXCLUDED_TITLE_KEYWORDS = ['sondermagazin', 'reisen', 'bestellmagazin']; + const withoutSondermagazin = all.filter(b => { + const t = (b.rawName || '').toLowerCase(); + return !EXCLUDED_TITLE_KEYWORDS.some(kw => t.includes(kw)); + }).map(({ rawName, ...rest }) => rest); // rawName war nur intern nötig + const targeted = withoutSondermagazin.filter(b => b.isTarget); + + // Chronologisch in "aktuell laufend" und "kommend" trennen + const todayIso = new Date().toISOString().slice(0, 10); + const isCurrent = b => { + const from = (b.validFrom || '').slice(0, 10); + const to = (b.validTo || '').slice(0, 10); + if (!from) return true; // ohne Datum lieber anzeigen als verstecken + if (from > todayIso) return false; // startet erst noch + if (to && to < todayIso) return false; // schon abgelaufen + return true; + }; + const byValidFrom = (a, b) => (a.validFrom || '').localeCompare(b.validFrom || ''); + const current = targeted.filter(isCurrent).sort(byValidFrom); + const future = targeted.filter(b => !isCurrent(b)).sort(byValidFrom); + + const result = { + current, + future, + scrapedAt: new Date().toISOString(), + }; + console.log(`🍺 KöPi Prospekte: ${current.length} aktuell laufend, ${future.length} kommend, ${current.filter(b=>b.hasKoenigPilsener).length} davon aktuell mit König Pilsener im Angebot`); + cacheSet(cacheKey, result); + return result; +} + +// ── Bild-Proxy (für evtl. externe Bilder) ─────────────────────────────────── +router.get('/img', authenticate, (req, res) => { + const url = req.query.url; + if (!url || !url.startsWith('https://')) return res.status(400).end(); + try { + const parsed = new URL(url); + const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz']; + if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end(); + const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => { + res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + imgRes.pipe(res); + }); + r.on('error', () => res.status(502).end()); + } catch { res.status(400).end(); } +}); + +// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ───────────────── +const KOEPI_SETTINGS_KEY = 'koepi_last_offers'; + +// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig). +// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der +// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag +// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts +// geändert hat. +function offerSignature(offers) { + return offers + .map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`) + .sort() + .join('\n'); +} + +function formatOfferMessage(offers) { + if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.'; + return offers + .map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`) + .join('\n'); +} + +// onlyUserId gesetzt: nur an diesen einen Nutzer schicken (z.B. Admin-Testklick), +// sonst an alle Nutzer mit hinterlegtem Pushover (echter Tages-Cron) +async function sendKoepiPushover(offers, onlyUserId = null) { + const recipients = onlyUserId + ? db.prepare(` + SELECT user_id, user_key, app_token FROM pushover_settings + WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL + `).all(onlyUserId) + : db.prepare(` + SELECT user_id, user_key, app_token FROM pushover_settings + WHERE user_key IS NOT NULL AND app_token IS NOT NULL + `).all(); + const message = formatOfferMessage(offers); + const title = '🍺 KöPi Angebote'; + for (const r of recipients) { + try { + await fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + token: r.app_token, + user: r.user_key, + title, + message, + priority: 0, + }), + }); + logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi' }); + } catch (e) { + console.error('KöPi Pushover Fehler:', e.message); + logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi', success: false }); + } + } +} + +// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch +// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den +// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv +// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt). +async function refreshOffersOnly() { + try { + const { offers } = await scrapeMarktguru(true); + const currentSig = offerSignature(offers); + const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || ''; + const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus + mqttClient.publishKoepiState({ offers, changed }); + console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`); + return { changed, sent: false, offerCount: offers.length }; + } catch (e) { + console.error('KöPi Refresh Fehler:', e.message); + return { error: e.message }; + } +} + +// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen (ohne onlyUserId +// → geht an alle). forceNotify=true (manueller Admin-Test): Pushover wird auch +// ohne Änderung verschickt; onlyUserId gesetzt: nur an diesen einen Nutzer. +// Löst die Filial-Whitelist automatisch neu auf, wenn die letzte Auflösung +// mehr als 6 Tage her ist — läuft als Teil des täglichen 06:00-Checks, damit +// ein einmalig falscher/veralteter Treffer sich von selbst korrigiert, statt +// dauerhaft stehen zu bleiben. +const RESOLVE_MAX_AGE_DAYS = 6; +async function resolveLocalStoresIfStale() { + const row = db.prepare('SELECT MIN(resolved_at) AS oldest FROM koepi_local_stores').get(); + const oldest = row?.oldest; + const staleOrMissing = !oldest || + (Date.now() - new Date(oldest.replace(' ', 'T')).getTime()) > RESOLVE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; + if (!staleOrMissing) return; + console.log(`🍺 KöPi: Filial-Whitelist ist älter als ${RESOLVE_MAX_AGE_DAYS} Tage (oder unvollständig) — löse automatisch neu auf...`); + try { await resolveLocalStores(); } + catch (e) { console.error('🍺 KöPi: automatische Neu-Auflösung fehlgeschlagen:', e.message); } +} + +async function runDailyCheck(forceNotify = false, onlyUserId = null) { + try { + await resolveLocalStoresIfStale(); + const { offers } = await scrapeMarktguru(true); + const currentSig = offerSignature(offers); + const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || ''; + const changed = currentSig !== lastSig; + + if (!changed && !forceNotify) { + console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.'); + mqttClient.publishKoepiState({ offers, changed: false }); + return { changed: false, sent: false, offerCount: offers.length }; + } + + await sendKoepiPushover(offers, onlyUserId); + db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)') + .run(KOEPI_SETTINGS_KEY, currentSig); + console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} – Pushover versendet${onlyUserId ? ` (nur an User ${onlyUserId})` : ''}.`); + mqttClient.publishKoepiState({ offers, changed }); + return { changed, sent: true, offerCount: offers.length }; + } catch (e) { + console.error('KöPi Tages-Check Fehler:', e.message); + return { error: e.message }; + } +} + +// ── Routes ──────────────────────────────────────────────────────────────────── +router.get('/offers', authenticate, async (req, res) => { + try { res.json(await scrapeMarktguru()); } + catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); } +}); + +router.get('/prospekte', authenticate, async (req, res) => { + try { res.json(await scrapeProspekte()); } + catch(e) { + console.error('🍺 KöPi Prospekte-Fehler:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +router.post('/clear-cache', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + ['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]); + res.json({ ok: true }); +}); + +// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte +// Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer) +// GET /local-stores – aktuelle Filial-Whitelist anzeigen (Admin) +router.get('/local-stores', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all(); + res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length }); +}); + +// POST /local-stores/resolve – Filial-Whitelist neu auflösen (Admin, z.B. wenn +// sich ein Prospekt-Link geändert hat oder eine Filiale ergänzt wurde) +router.post('/local-stores/resolve', authenticate, async (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + try { + const resolved = await resolveLocalStores(); + const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all(); + res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length, stores }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +router.post('/run-daily-check', authenticate, async (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + try { + const result = await runDailyCheck(true, req.user.id); + if (result.error) return res.status(500).json({ error: result.error }); + res.json(result); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// ── Öffentliche Teilen-Links (kein Login nötig) ───────────────────────────── +// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH +// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein +// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP +// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten +// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten. +// +// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit +// in den Logs nachvollziehbar ist, WER welchen Link benutzt hat. +function getShareLinkByToken(token) { + return db.prepare('SELECT id, name FROM koepi_share_links WHERE token=?').get(token) || null; +} + +// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst +// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig +const publicRateLimits = new Map(); +function publicRateLimit(req, res, next) { + const ip = req.ip || 'unknown'; + const now = Date.now(); + const windowMs = 60000, maxReq = 30; + const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs); + if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' }); + arr.push(now); + publicRateLimits.set(ip, arr); + next(); +} +// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst +setInterval(() => { + const now = Date.now(); + for (const [ip, arr] of publicRateLimits) { + const fresh = arr.filter(t => now - t < 60000); + if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip); + } +}, 5 * 60 * 1000); + +// GET /share-links – alle benannten Links auflisten (Admin) +router.get('/share-links', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all() + .map(l => ({ ...l, url: `${req.protocol}://${req.get('host')}/kp/${l.token}` })); + res.json({ links }); +}); + +// POST /share-links – neuen benannten Link erstellen (Admin) +router.post('/share-links', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const name = (req.body?.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Name fehlt' }); + const token = crypto.randomBytes(20).toString('hex'); + db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name); + const link = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links WHERE token=?').get(token); + res.json({ ...link, url: `${req.protocol}://${req.get('host')}/kp/${token}` }); +}); + +// POST /share-links/:id/reset – Token dieses Links neu erzeugen (Name bleibt, +// alter Link wird ungültig) +router.post('/share-links/:id/reset', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + const existing = db.prepare('SELECT * FROM koepi_share_links WHERE id=?').get(req.params.id); + if (!existing) return res.status(404).json({ error: 'Nicht gefunden' }); + const token = crypto.randomBytes(20).toString('hex'); + db.prepare('UPDATE koepi_share_links SET token=? WHERE id=?').run(token, req.params.id); + res.json({ id: existing.id, token, name: existing.name, url: `${req.protocol}://${req.get('host')}/kp/${token}` }); +}); + +// DELETE /share-links/:id – Link löschen (Admin) +router.delete('/share-links/:id', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('DELETE FROM koepi_share_links WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); + +// GET /public/:token/manifest.json – eigenes PWA-Manifest für die öffentliche +// Prospekt-Seite: eigener Name ("Dickens Prospekte") und eigener start_url, +// damit die installierte App direkt wieder auf der öffentlichen Seite landet +// statt auf dem normalen Login der Haupt-App. +router.get('/public/:token/manifest.json', (req, res) => { + const link = getShareLinkByToken(req.params.token); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + res.setHeader('Content-Type', 'application/manifest+json'); + res.json({ + name: 'Dickens Prospekte', + short_name: 'Prospekte', + description: 'Wöchentliche Prospekte', + start_url: `/kp/${req.params.token}`, + scope: `/kp/${req.params.token}`, + display: 'standalone', + orientation: 'portrait', + background_color: '#0f1117', + theme_color: '#0f1117', + icons: [ + { src: '/favicon.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any maskable' }, + { src: '/icon-192.png', type: 'image/png', sizes: '192x192' }, + { src: '/icon-512.png', type: 'image/png', sizes: '512x512' }, + ], + }); +}); + +// GET /public/:token/offers – öffentlich, nur Cache, kein Login +router.get('/public/:token/offers', publicRateLimit, async (req, res) => { + const link = getShareLinkByToken(req.params.token); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + try { + const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route + res.json(result); + } catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); } +}); + +// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login +router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => { + const link = getShareLinkByToken(req.params.token); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'], linkName: link.name }); + try { + const result = await scrapeProspekte(); + res.json(result); + } catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); } +}); + +module.exports = router; +module.exports.runDailyCheck = runDailyCheck; +module.exports.refreshOffersOnly = refreshOffersOnly; +module.exports.resolveLocalStores = resolveLocalStores; diff --git a/backend/src/tools/linkliste/routes.js b/backend/src/tools/linkliste/routes.js new file mode 100644 index 0000000..72fa2de --- /dev/null +++ b/backend/src/tools/linkliste/routes.js @@ -0,0 +1,212 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── Ordner ──────────────────────────────────────────────────────────────────── +router.get('/folders', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(req.user.id)); +}); + +router.post('/folders', authenticate, (req, res) => { + const uid = req.user.id; + const { name, icon='📁' } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM link_list_folders WHERE user_id=?').get(uid); + const r = db.prepare(`INSERT INTO link_list_folders (user_id,name,icon,sort_order,created_at) VALUES (?,?,?,?,datetime('now','localtime'))`) + .run(uid, name.trim(), icon, (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/folders/:id', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const { name=f.name, icon=f.icon, in_quickaccess } = req.body; + const qa = in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : f.in_quickaccess; + db.prepare('UPDATE link_list_folders SET name=?, icon=?, in_quickaccess=? WHERE id=?').run(name, icon, qa, f.id); + res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(f.id)); +}); + +router.delete('/folders/:id', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + // Links aus Ordner herauslösen + db.prepare('UPDATE link_list SET folder_id=NULL WHERE folder_id=? AND user_id=?').run(f.id, req.user.id); + db.prepare('DELETE FROM link_list_folders WHERE id=?').run(f.id); + res.json({ ok: true }); +}); + +// Ordner-Inhalt abrufen (für Folder-Modal im Dashboard) +router.get('/folders/:id/links', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const links = db.prepare('SELECT * FROM link_list WHERE folder_id=? AND user_id=? ORDER BY sort_order,id').all(f.id, req.user.id); + res.json({ folder: f, links }); +}); + +// ── Sortierung ──────────────────────────────────────────────────────────────── +router.put('/sort', authenticate, (req, res) => { + const uid = req.user.id; + const { links=[], folders=[] } = req.body; + const upLink = db.prepare('UPDATE link_list SET sort_order=? WHERE id=? AND user_id=?'); + const upFolder = db.prepare('UPDATE link_list_folders SET sort_order=? WHERE id=? AND user_id=?'); + db.transaction(() => { + links.forEach((id, i) => upLink.run(i, id, uid)); + folders.forEach((id, i) => upFolder.run(i, id, uid)); + })(); + res.json({ ok: true }); +}); + +// ── Eigene Links ────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const uid = req.user.id; + const own = db.prepare(` + SELECT l.*, 0 as is_shared, NULL as owner_name, + (SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count + FROM link_list l WHERE l.user_id=? ORDER BY l.folder_id IS NULL DESC, l.folder_id, l.sort_order, l.id + `).all(uid); + + const folders = db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(uid); + + const shared = db.prepare(` + SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count + FROM link_list l + JOIN link_list_shares s ON s.link_id=l.id + JOIN users u ON u.id=l.user_id + WHERE s.shared_with=? + ORDER BY l.created_at DESC + `).all(uid); + + const sharedByMe = db.prepare(` + SELECT l.*, 0 as is_shared, u.username as shared_with_name + FROM link_list l + JOIN link_list_shares s ON s.link_id=l.id + JOIN users u ON u.id=s.shared_with + WHERE l.user_id=? + ORDER BY l.title ASC + `).all(uid); + + // Ordner die mit mir geteilt wurden (inkl. deren Links) + const sharedFolders = db.prepare(` + SELECT f.*, u.username as owner_name + FROM link_list_folders f + JOIN link_folder_shares s ON s.folder_id=f.id + JOIN users u ON u.id=f.user_id + WHERE s.shared_with=? + ORDER BY f.name ASC + `).all(uid); + + const sharedFolderLinks = sharedFolders.map(folder => ({ + ...folder, + links: db.prepare('SELECT * FROM link_list WHERE folder_id=? ORDER BY sort_order,id').all(folder.id) + })); + + // Ordner die ich geteilt habe + const sharedFoldersByMe = db.prepare(` + SELECT f.*, u.username as shared_with_name + FROM link_list_folders f + JOIN link_folder_shares s ON s.folder_id=f.id + JOIN users u ON u.id=s.shared_with + WHERE f.user_id=? + ORDER BY f.name ASC + `).all(uid); + + res.json({ own, folders, shared, sharedByMe, sharedFolders: sharedFolderLinks, sharedFoldersByMe }); +}); + +router.post('/', authenticate, (req, res) => { + const uid = req.user.id; + const { title, url, icon = '🔗', description = '', folder_id = null } = req.body; + if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM link_list WHERE user_id=?').get(uid); + const r = db.prepare(` + INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at) + VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) + `).run(uid, title.trim(), url.trim(), icon, description.trim(), folder_id || null, (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/:id', authenticate, (req, res) => { + const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + const { title, url, icon, description, folder_id, in_quickaccess } = req.body; + db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=?, folder_id=?, in_quickaccess=? WHERE id=?') + .run( + title ?? link.title, + url ?? link.url, + icon ?? link.icon, + description ?? link.description, + folder_id !== undefined ? (folder_id || null) : link.folder_id, + in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : link.in_quickaccess, + link.id + ); + res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM link_list WHERE id=? AND user_id=?').run(req.params.id, req.user.id); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ ok: true }); +}); + +// ── Shares ──────────────────────────────────────────────────────────────────── +router.get('/:id/shares', authenticate, (req, res) => { + const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(db.prepare(` + SELECT u.id, u.username, s.created_at as shared_at + FROM link_list_shares s JOIN users u ON u.id=s.shared_with + WHERE s.link_id=? + `).all(link.id)); +}); + +router.post('/:id/share', authenticate, (req, res) => { + const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + db.prepare(`INSERT OR IGNORE INTO link_list_shares (link_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`) + .run(link.id, req.user.id, target.id); + res.json({ ok: true }); +}); + +router.delete('/:id/share/:userId', authenticate, (req, res) => { + const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM link_list_shares WHERE link_id=? AND shared_with=?').run(link.id, req.params.userId); + res.json({ ok: true }); +}); + +// ── Ordner-Shares ───────────────────────────────────────────────────────────── +router.get('/folders/:id/shares', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const shares = db.prepare(` + SELECT u.id, u.username, s.created_at as shared_at + FROM link_folder_shares s JOIN users u ON u.id=s.shared_with + WHERE s.folder_id=? + `).all(f.id); + res.json(shares); +}); + +router.post('/folders/:id/share', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + db.prepare(`INSERT OR IGNORE INTO link_folder_shares (folder_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`) + .run(f.id, req.user.id, target.id); + res.json({ ok: true }); +}); + +router.delete('/folders/:id/share/:userId', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM link_folder_shares WHERE folder_id=? AND shared_with=?').run(f.id, req.params.userId); + res.json({ ok: true }); +}); + + +module.exports = router; diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js new file mode 100644 index 0000000..180880b --- /dev/null +++ b/backend/src/tools/media/routes.js @@ -0,0 +1,1265 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { logPush } = require('../../pushLog'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); + +const TMDB_BASE = 'https://api.themoviedb.org/3'; +const XREL_BASE = 'https://api.xrel.to/v2'; +const THIS_YEAR = new Date().getFullYear(); +const MIN_YEAR = THIS_YEAR - 1; + +// Sprachen die wir NICHT wollen +const BLOCKED_LANGS = new Set(['tr', 'hi', 'te', 'ta', 'ml', 'kn', 'bn', 'mr', 'pa']); + +// Genre-IDs die wir nicht wollen (nur wenn das der EINZIGE Genre ist) +// 16=Animation, 99=Documentary – aber nur rausfiltern wenn explizit kein Spielfilm-Genre dabei +const DOC_GENRE_ID = 99; + +// Umlaut-Normalisierung für xREL-Suche (Nürnberg → Nuernberg etc.) +function normalizeUmlauts(s) { + return (s||'') + .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue') + .replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue') + .replace(/ß/g,'ss'); +} + +// ── SQLite Cache ────────────────────────────────────────────────────────────── +function cacheGet(key) { + const row = db.prepare('SELECT value, expires_at FROM xrel_cache WHERE cache_key=?').get(key); + if (!row) return undefined; + if (Date.now() > row.expires_at) { db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); return undefined; } + try { return JSON.parse(row.value); } catch { return undefined; } +} +function cacheSet(key, value, ttlMs = 12 * 60 * 60 * 1000) { + db.prepare('INSERT OR REPLACE INTO xrel_cache (cache_key,value,expires_at) VALUES (?,?,?)') + .run(key, JSON.stringify(value), Date.now() + ttlMs); +} +function cacheDelete(key) { + db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(key); +} + +// ── TMDb ────────────────────────────────────────────────────────────────────── +function getToken() { + return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; +} +async function tmdb(path, params = {}) { + const token = getToken(); + if (!token) throw new Error('Kein TMDb API-Token konfiguriert'); + const url = new URL(TMDB_BASE + path); + url.searchParams.set('language', 'de-DE'); + url.searchParams.set('region', 'DE'); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); + if (!res.ok) throw new Error(`TMDb ${res.status}`); + return res.json(); +} +let genreCache = null; +async function getGenreMap() { + if (genreCache) return genreCache; + const d = await tmdb('/genre/movie/list'); + genreCache = {}; + for (const g of d.genres) genreCache[g.id] = g.name; + return genreCache; +} +function extractFSK(rd) { + const de = rd?.results?.find(r => r.iso_3166_1 === 'DE'); + if (!de?.release_dates?.length) return null; + const sorted = [...de.release_dates].sort((a, b) => a.type === 3 ? -1 : 1); + for (const r of sorted) { const c = (r.certification||'').trim(); if (c) return c; } + return null; +} + +const RELEASE_TYPE_LABEL = { 2:'Kino (limited)', 3:'Kinostart', 4:'Web', 5:'Retail', 6:'TV' }; + +function extractReleaseDates(rd) { + if (!rd?.results) return []; + // Nur DE und US, Typen 2-5 (kein Premiere=1, kein TV=6) + const entries = []; + for (const country of ['DE', 'US']) { + const c = rd.results.find(r => r.iso_3166_1 === country); + if (!c) continue; + for (const r of c.release_dates) { + if (!r.release_date) continue; + const label = RELEASE_TYPE_LABEL[r.type]; + if (!label) continue; + entries.push({ + country, + label: `${label} (${country})`, + date: r.release_date.slice(0, 10), + }); + } + } + entries.sort((a, b) => a.date.localeCompare(b.date)); + return entries; +} +async function fetchAllPages(path, maxPages = 3) { + const first = await tmdb(path, { page: 1 }); + const total = Math.min(first.total_pages || 1, maxPages); + let results = [...first.results]; + if (total > 1) { + const extra = await Promise.all(Array.from({ length: total-1 }, (_,i) => tmdb(path, { page: i+2 }))); + for (const p of extra) results = results.concat(p.results); + } + return results; +} +function filterMovies(list) { + const seen = new Set(); + return list.filter(m => { + if (BLOCKED_LANGS.has(m.original_language)) return false; + const y = m.release_date ? parseInt(m.release_date.slice(0,4)) : 0; + if (y > 0 && y < MIN_YEAR) return false; + if (seen.has(m.id)) return false; + seen.add(m.id); + // Reine Dokus rausfiltern + const genres = m.genre_ids || []; + if (genres.length === 1 && genres[0] === DOC_GENRE_ID) return false; + if (genres.length === 0 && m.vote_count < 5) return false; + return true; + }); +} +function enrichMovie(m, genreMap, fsk, rank) { + return { + id: m.id, title: m.title||m.original_title, original_title: m.original_title, + original_language: m.original_language, poster_path: m.poster_path, + overview: m.overview, release_date: m.release_date, vote_average: m.vote_average, + genres: (m.genre_ids||[]).map(id => genreMap[id]).filter(Boolean), + fsk: fsk??null, rank: rank??null, + }; +} + +// ── xREL ───────────────────────────────────────────────────────────────────── +const XREL_DATE_LABEL = { + 'de-cine': 'Kinostart (DE)', + 'us-cine': 'Kinostart (US)', + 'de-web': 'Web (DE)', + 'us-web': 'Web (US)', + 'de-retail': 'Retail (DE)', + 'us-retail': 'Retail (US)', + 'de-hd': 'Blu-ray (DE)', + 'us-hd': 'Blu-ray (US)', + 'de-rental': 'Rental (DE)', + 'us-rental': 'Rental (US)', + 'r5': 'R5', +}; + +const QUALITY_BLOCKLIST = r => /\b(2160p|4k|uhd|uhdbluray|uhdbd|remux)\b/i.test(r) || /COMPLETE[._](?:BLURAY|BD)/i.test(r); + +async function xrelFetch(path, params = {}) { + const url = new URL(XREL_BASE + path); + for (const [k,v] of Object.entries(params)) url.searchParams.set(k, String(v)); + const res = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000), + }); + if (res.status === 429) { + await new Promise(r => setTimeout(r, (parseInt(res.headers.get('Retry-After')||'10'))*1000)); + const r2 = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(7000) }); + if (!r2.ok) throw new Error(`xREL ${r2.status}`); + return r2.json(); + } + if (!res.ok) throw new Error(`xREL ${res.status}`); + return res.json(); +} + +// Einzige Funktion für xREL: benutzt immer die ext_info_id und gibt max 5 deutsche ≤1080p Releases +// Cache-Key: tmdb_id (immer eindeutig, konsistent zwischen Badge und Modal) +async function getXrelData(tmdbId, imdbId, title, originalTitle, movieYear, mediaType='movie') { + const cacheKey = `xrel:${mediaType}:${tmdbId}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return cached; + + const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); + + // Suchqueries: original, deutsch, und normalisierte Varianten + // Wichtig: xREL nutzt oft den englischen Originaltitel auch bei deutschen Filmen + const rawQueries = [originalTitle, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); + // Normalisierte Versionen (Umlaute) als zusätzliche Queries + const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim()) + .filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q)); + const allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); + + // Alle parallel suchen + const searchResults = await Promise.all(allQueries.map(async q => { + try { + return { + q, + data: await xrelFetch('/search/ext_info.json', { + q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:mediaType, limit:10 + }) + }; + } catch(_) { return null; } + })); + + let extInfoId = null; + + // Strategie 1: IMDb-URI exakter Match (zuverlässigste Methode) + if (imdbId) { + for (const r of searchResults) { + if (!r) continue; + const hit = (r.data?.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdbId}`)); + if (hit?.id) { extInfoId = hit.id; break; } + } + } + + // Strategie 2: Titel/alt_title Match gegen alle Queries (normalisiert) + if (!extInfoId) { + const allCleanQueries = allQueries.map(q => clean(q)); + for (const r of searchResults) { + if (!r?.data?.results) continue; + for (const h of r.data.results) { + if (allCleanQueries.includes(clean(h.title)) || allCleanQueries.includes(clean(h.alt_title))) { + extInfoId = h.id; + break; + } + } + if (extInfoId) break; + } + } + + if (!extInfoId) { + cacheSet(cacheKey, [], 60*60*1000); + return []; + } + + // Releases + ext_info Details parallel holen + const [sceneData, p2pData, infoData] = await Promise.all([ + xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 50 }).catch(()=>({})), + xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 50 }).catch(()=>({})), + xrelFetch('/ext_info/info.json', { id: extInfoId, type: mediaType }).catch(()=>({})), + ]); + + // xREL Erscheinungsdaten + const xrel_dates = (infoData.release_dates||[]) + .filter(d => XREL_DATE_LABEL[d.type]) + .map(d => ({ label: XREL_DATE_LABEL[d.type], date: d.date })) + .sort((a,b) => a.date.localeCompare(b.date)); + + const results = []; + + // Jahresfilter: nur für Filme ab 2015 anwenden + // Alte Filme (< 2015) haben oft Releases die Jahre später erschienen → nicht filtern + const releaseYearOk = (releaseDate) => { + if (!movieYear || !releaseDate) return true; + if (movieYear < 2015) return true; + if (mediaType === 'tv') return true; // TV-Serien: kein Jahresfilter (Staffeln erscheinen über Jahre) + const y = parseInt(releaseDate.slice(0,4)); + return Math.abs(y - movieYear) <= 3; + }; + + for (const r of sceneData.list||[]) { + if (!/\b(german|deutsch)(\.dl)?\b/i.test(r.dirname||'')) continue; + if (QUALITY_BLOCKLIST(r.dirname||'')) continue; + const date = r.time ? new Date(r.time*1000).toISOString().slice(0,10) : null; + if (!releaseYearOk(date)) continue; + results.push({ + dirname: r.dirname, size: r.size ? `${r.size.number} ${r.size.unit}` : null, + category: 'Scene', url: r.link_href, date, p2p: false, + }); + } + for (const r of p2pData.list||[]) { + if (r.main_lang !== 'german') continue; + if (QUALITY_BLOCKLIST(r.dirname||'')) continue; + const date = r.pub_time ? new Date(r.pub_time*1000).toISOString().slice(0,10) : null; + if (!releaseYearOk(date)) continue; + results.push({ + dirname: r.dirname, size: r.size_mb ? `${(r.size_mb/1024).toFixed(1)} GB` : null, + category: r.category?.sub_cat||'P2P', url: r.link_href, date, p2p: true, + }); + } + + results.sort((a,b) => (b.date||'').localeCompare(a.date||'')); + const top5 = results.slice(0, 5); + const payload = { releases: top5, xrel_dates }; + cacheSet(cacheKey, payload, top5.length > 0 || xrel_dates.length > 0 ? 12*60*60*1000 : 60*60*1000); + return payload; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +router.get('/now-playing', authenticate, async (req, res) => { + try { + const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/now_playing',3), getGenreMap()]); + const filtered = filterMovies(pages); + const top10raw = filtered.slice(0,10), restRaw = filtered.slice(10); + const top10 = await Promise.all(top10raw.map(async (m,i) => { + let fsk = null; + try { fsk = extractFSK(await tmdb(`/movie/${m.id}/release_dates`)); } catch(_) {} + return enrichMovie(m, genreMap, fsk, i+1); + })); + res.json([...top10, ...restRaw.map(m => enrichMovie(m,genreMap,null,null))]); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +router.get('/upcoming', authenticate, async (req, res) => { + try { + const [pages, genreMap] = await Promise.all([fetchAllPages('/movie/upcoming',3), getGenreMap()]); + const filtered = filterMovies(pages).filter(m => m.release_date); + filtered.sort((a,b) => a.release_date.localeCompare(b.release_date)); + const groups = {}; + for (const m of filtered) { + const d = new Date(m.release_date), dow = d.getDay(); + const mon = new Date(d); mon.setDate(d.getDate()+(dow===0?-6:1-dow)); + const key = mon.toISOString().slice(0,10); + if (!groups[key]) groups[key] = []; + groups[key].push(enrichMovie(m,genreMap,null,null)); + } + res.json(groups); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// GET /api/tools/media/search?q=TITEL – Filmsuche mit Wildcard-Support +// hulk*2008 → sucht "hulk" mit year=2008 +router.get('/search', authenticate, async (req, res) => { + try { + let q = (req.query.q || '').trim(); + if (!q || q.length < 2) return res.json([]); + + // Wildcard: z.B. "hulk*2008" → query="hulk", year="2008" + let yearFilter = null; + const wildcardMatch = q.match(/^(.+?)\*(\d{4})$/); + if (wildcardMatch) { + q = wildcardMatch[1].trim(); + yearFilter = wildcardMatch[2]; + } + + const searchParams = { query: q, include_adult: 'false' }; + if (yearFilter) searchParams.year = yearFilter; + + const [movieResults, tvResults, genreMap, tvGenreMap] = await Promise.all([ + tmdb('/search/movie', searchParams), + tmdb('/search/tv', { query: q, include_adult: 'false', ...(yearFilter ? { first_air_date_year: yearFilter } : {}) }), + getGenreMap(), + tmdb('/genre/tv/list').catch(()=>({genres:[]})), + ]); + const tvGenres = {}; + (tvGenreMap.genres||[]).forEach(g => { tvGenres[g.id] = g.name; }); + + const seen = new Set(); + const movies = (movieResults.results || []) + .filter(m => { if (BLOCKED_LANGS.has(m.original_language)) return false; if (seen.has('m'+m.id)) return false; seen.add('m'+m.id); return true; }) + .map(m => ({ ...enrichMovie(m, genreMap, null, null), media_type: 'movie' })); + + const tvShows = (tvResults.results || []) + .filter(m => { if (seen.has('t'+m.id)) return false; seen.add('t'+m.id); return true; }) + .map(m => ({ + id: m.id, media_type: 'tv', + title: m.name || m.original_name, + original_title: m.original_name, + release_date: m.first_air_date || '', + poster_path: m.poster_path || '', + genres: (m.genre_ids||[]).map(id => tvGenres[id] || genreMap[id]).filter(Boolean), + vote_average: m.vote_average, + original_language: m.original_language, + origin_country: m.origin_country || [], + })); + + // Sortierung: mit Datum zuerst (neueste), ohne Datum danach + const combined = [...movies, ...tvShows] + .sort((a,b) => { + if (!a.release_date && !b.release_date) return 0; + if (!a.release_date) return 1; + if (!b.release_date) return -1; + return b.release_date.localeCompare(a.release_date); + }) + .slice(0, 30); + + res.json(combined); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// TV-Serie Detail +router.get('/tv/:id', authenticate, async (req, res) => { + try { + const [detail, genreMap, extIds] = await Promise.all([ + tmdb(`/tv/${req.params.id}`, { append_to_response: 'credits,content_ratings' }), + getGenreMap(), + tmdb(`/tv/${req.params.id}/external_ids`).catch(()=>({})), + ]); + // FSK aus content_ratings (DE) + let fsk = ''; + const deRating = (detail.content_ratings?.results || []).find(r => r.iso_3166_1 === 'DE'); + if (deRating?.rating) fsk = deRating.rating; + + res.json({ + id: detail.id, media_type: 'tv', + title: detail.name || detail.original_name, + original_title: detail.original_name, + tagline: detail.tagline || '', + overview: detail.overview, + poster_path: detail.poster_path, + backdrop_path: detail.backdrop_path, + release_date: detail.first_air_date || '', + runtime: detail.episode_run_time?.[0] || null, + vote_average: detail.vote_average, + genres: (detail.genres||[]).map(g=>g.name), + fsk, + cast: (detail.credits?.cast||[]).slice(0,8).map(c => c.name), + countries: (detail.production_countries||[]).map(c => c.name), + imdb_id: extIds.imdb_id || '', + number_of_seasons: detail.number_of_seasons, + number_of_episodes: detail.number_of_episodes, + status: detail.status, + }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// Modal: TMDb-Daten sofort, xREL via separaten Endpoint +router.get('/movie/:id', authenticate, async (req, res) => { + try { + const [detail, genreMap, extIds] = await Promise.all([ + tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }), + getGenreMap(), + tmdb(`/movie/${req.params.id}/external_ids`).catch(()=>({})), + ]); + const fsk = extractFSK(detail.release_dates); + const imdbId = extIds.imdb_id || null; + res.json({ + id: detail.id, title: detail.title||detail.original_title, + original_title: detail.original_title, tagline: detail.tagline, + overview: detail.overview, poster_path: detail.poster_path, + backdrop_path: detail.backdrop_path, release_date: detail.release_date, + runtime: detail.runtime, vote_average: detail.vote_average, + genres: (detail.genres||[]).map(g => g.name), fsk, + cast: (detail.credits?.cast||[]).slice(0,8).map(a => a.name), + director: detail.credits?.crew?.find(c => c.job==='Director')?.name||null, + tmdb_url: `https://www.themoviedb.org/movie/${detail.id}`, + imdb_url: imdbId ? `https://www.imdb.com/title/${imdbId}` : null, + imdb_id: imdbId, + countries: (detail.production_countries||[]).map(c => c.name), + }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// xREL on-demand – ?refresh=1 ignoriert Cache, ?imdb_id=tt...&title=...&orig=... vermeidet TMDb-Calls +router.get('/movie/:id/xrel', authenticate, async (req, res) => { + try { + const xrelMediaType = req.query.media_type || 'movie'; + const cacheKey = `xrel:${xrelMediaType}:${req.params.id}`; + const forceRefresh = req.query.refresh === '1'; + + if (!forceRefresh) { + const cached = cacheGet(cacheKey); + if (cached !== undefined) { + const releases = Array.isArray(cached) ? cached : (cached.releases || []); + const xrel_dates = Array.isArray(cached) ? [] : (cached.xrel_dates || []); + if (releases.length > 0 || xrel_dates.length > 0) { + return res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 }); + } + } + } + if (forceRefresh) { + db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(cacheKey); + // Auch alten Key löschen + db.prepare('DELETE FROM xrel_cache WHERE cache_key=?').run(`xrel:tmdb:${req.params.id}`); + } + + let imdbId = req.query.imdb_id || null; + let title = req.query.title || null; + let origTitle = req.query.orig || null; + + if (!title) { + const endpoint = xrelMediaType === 'tv' ? 'tv' : 'movie'; + const [detail, extIds] = await Promise.all([ + tmdb(`/${endpoint}/${req.params.id}`, { append_to_response: '' }).catch(()=>({})), + tmdb(`/${endpoint}/${req.params.id}/external_ids`).catch(()=>({})), + ]); + imdbId = extIds.imdb_id || null; + title = detail.name || detail.title || null; + origTitle = detail.original_name || detail.original_title || null; + } + + let filmYear = null; + if (req.query.year && req.query.year !== '0' && xrelMediaType !== 'tv') filmYear = parseInt(req.query.year); + const data = await getXrelData(req.params.id, imdbId, title, origTitle, filmYear, xrelMediaType); + const releases = Array.isArray(data) ? data : (data.releases || []); + const xrel_dates = Array.isArray(data) ? [] : (data.xrel_dates || []); + res.json({ xrel: releases, xrel_dates, has_xrel: releases.length > 0 }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + + + +// GET /api/tools/media/xrel-cached?ids=1,2,3 – welche Filme haben Releases im Cache +router.get('/xrel-cached', authenticate, (req, res) => { + const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); + if (!ids.length) return res.json({}); + const result = {}; + for (const id of ids) { + const cached = cacheGet(`xrel:movie:${id}`) || cacheGet(`xrel:tv:${id}`) || cacheGet(`xrel:tmdb:${id}`); + result[id] = cached !== undefined && (Array.isArray(cached) ? cached.length > 0 : cached.releases?.length > 0); + } + res.json(result); +}); + +// ── Streaming Top 10 pro Anbieter ────────────────────────────────────────────── +// TMDb Watch-Provider-IDs für Deutschland (watch_region=DE) +// HINWEIS: Eine frühere Version hat hier versucht die offizielle Netflix-XLSX-Datei +// (komplette Historie seit 2021, alle ~90 Länder) direkt zu laden und zu parsen. +// Das hat den Container überlastet (CPU ~90%, RAM voll, Container kurzzeitig nicht +// erreichbar) – die Datei ist für ein synchrones Parsing im selben Prozess zu groß. +// Netflix läuft daher wie alle anderen Anbieter über die leichtgewichtige +// TMDb-Näherung weiter unten. + +// HINWEIS: Ein Versuch, die Netflix-Tudum-HTML-Seite zu parsen, wurde wieder +// entfernt – das echte Markup ist modernes React/Next.js mit CSS-in-JS +// (emotion-Klassen), keine saubere serverseitig gerenderte Tabelle wie +// angenommen. Das Regex-Parsing fand 0 Treffer und wäre zudem bei jedem +// Netflix-Frontend-Update erneut kaputt gegangen. Netflix läuft daher wie +// alle anderen Anbieter über die TMDb-Näherung weiter unten. + +const STREAMING_PROVIDERS = [ + { id: 'netflix', label: 'Netflix', tmdbId: '8' }, + { id: 'disney', label: 'Disney+', tmdbId: '337' }, + { id: 'prime', label: 'Amazon Prime Video', tmdbId: '9|119' }, + { id: 'appletv', label: 'Apple TV+', tmdbId: '350' }, + { id: 'max', label: 'HBO Max', tmdbId: '1899' }, + { id: 'paramount', label: 'Paramount+', tmdbId: '531' }, + { id: 'wow', label: 'WOW (Sky)', tmdbId: '30' }, + { id: 'rtlplus', label: 'RTL+', tmdbId: '421' }, +]; + +// Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen +// MUSS vor /streaming/:providerId stehen, sonst matched Express "all-providers" als providerId! +router.get('/streaming/all-providers', authenticate, requireAdmin, async (req, res) => { + try { + const [movieProv, tvProv] = await Promise.all([ + tmdb('/watch/providers/movie', { watch_region: 'DE' }), + tmdb('/watch/providers/tv', { watch_region: 'DE' }), + ]); + const merged = {}; + for (const p of [...(movieProv.results||[]), ...(tvProv.results||[])]) { + merged[p.provider_id] = { id: p.provider_id, name: p.provider_name, logo: p.logo_path, + priority: p.display_priorities?.DE ?? p.display_priority ?? 999 }; + } + const list = Object.values(merged).sort((a,b) => a.priority - b.priority); + res.json(list); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +router.get('/streaming/providers', authenticate, (req, res) => { + res.json(STREAMING_PROVIDERS.map(p => ({ id: p.id, label: p.label }))); +}); + +// GET /streaming/:providerId – Top 10 Filme + Serien für einen Anbieter (DE) +// GET /streaming/:providerId/new – Neuerscheinungen (letzte 60 Tage, neueste zuerst) +// Zuverlässig für ALLE Anbieter, da nur release_date + watch_provider kombiniert wird – +// keine Sprach-/Popularitäts-Heuristik nötig, die Reihenfolge ergibt sich einfach +// aus dem tatsächlichen Veröffentlichungsdatum. +router.get('/streaming/:providerId/new', authenticate, async (req, res) => { + try { + const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); + if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); + + const cacheKey = `streaming-new:${provider.id}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) return res.json(cached); + + const today = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + })(); + function daysAgo(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + } + + function baseParams(mediaKind, sinceDate) { + return { + watch_region: 'DE', + with_watch_providers: provider.tmdbId, + with_watch_monetization_types: 'flatrate', + sort_by: 'popularity.desc', + ...(mediaKind === 'movie' + ? { 'primary_release_date.gte': sinceDate, 'primary_release_date.lte': today } + : { 'first_air_date.gte': sinceDate, 'first_air_date.lte': today }), + }; + } + + async function fetchWindow(mediaKind, sinceDate) { + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const [p1, p2] = await Promise.all([ + tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 1 }), + tmdb(path, { ...baseParams(mediaKind, sinceDate), page: 2 }), + ]); + return [...(p1.results||[]), ...(p2.results||[])].filter(m => !BLOCKED_LANGS.has(m.original_language)); + } + + // Manche Anbieter (z.B. Disney+, RTL+) haben bei TMDb nur sehr wenige Titel + // mit korrekt erfasstem Veröffentlichungsdatum + Verfügbarkeit verknüpft. + // Statt einer fast leeren Liste wird das Zeitfenster INDIVIDUELL pro Anbieter + // und getrennt für Filme/Serien schrittweise vergrößert, bis mindestens 10 + // Titel zusammenkommen – der tatsächlich verwendete Zeitraum wird im + // Ergebnis ehrlich mitgegeben (kein versteckter Fake-"neu"). + const WINDOW_STAGES = [21, 45, 90, 180, 365, 730]; + const TARGET_COUNT = 10; + async function fetchCandidatesWithExpansion(mediaKind) { + let usedWindow = WINDOW_STAGES[0]; + let result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[0])); + for (let i = 1; i < WINDOW_STAGES.length && result.length < TARGET_COUNT; i++) { + usedWindow = WINDOW_STAGES[i]; + result = await fetchWindow(mediaKind, daysAgo(WINDOW_STAGES[i])); + } + return { items: result, usedWindow }; + } + + // Gleiche Sprachgewichtung wie bei Top 10: Deutsch/Englisch unbegrenzt, + // alle anderen Sprachen zusammen max. 2 Plätze + const UNLIMITED_LANGS = new Set(['de', 'en']); + const MAX_OTHER_LANGUAGE_TOTAL = 2; + function applyLanguageWeighting(rawList) { + let otherCount = 0; + const picked = []; + const overflow = []; + for (const item of rawList) { + const lang = item.original_language || 'xx'; + if (UNLIMITED_LANGS.has(lang)) { + picked.push(item); + } else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) { + picked.push(item); + otherCount++; + } else { + overflow.push(item); + } + if (picked.length >= 10) break; + } + for (const item of overflow) { + if (picked.length >= 10) break; + picked.push(item); + } + return picked.slice(0, 10); + } + + const [movieResult, tvResult, genreMap] = await Promise.all([ + fetchCandidatesWithExpansion('movie'), + fetchCandidatesWithExpansion('tv'), + getGenreMap(), + ]); + + // Titel die bereits in der (gecachten) Top-10-Liste dieses Anbieters stehen + // aus den Neuerscheinungen ausschließen, damit beide Reiter sich nicht + // überschneiden, selbst wenn sich die Zeitfenster teilweise überlappen + const top10Cached = cacheGet(`streaming-top10:${provider.id}`); + const top10MovieIds = new Set((top10Cached?.movies || []).map(m => m.id)); + const top10TvIds = new Set((top10Cached?.tvShows || []).map(m => m.id)); + + const movies = applyLanguageWeighting(movieResult.items.filter(m => !top10MovieIds.has(m.id))) + .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); + + const tvShows = applyLanguageWeighting(tvResult.items.filter(m => !top10TvIds.has(m.id))) + .map((m, i) => ({ + id: m.id, media_type: 'tv', rank: i + 1, + title: m.name || m.original_name, + original_title: m.original_name, + release_date: m.first_air_date || '', + poster_path: m.poster_path || '', + genres: (m.genre_ids||[]).map(gid => genreMap[gid]).filter(Boolean), + vote_average: m.vote_average, + original_language: m.original_language, + origin_country: m.origin_country || [], + })); + + const result = { + provider: { id: provider.id, label: provider.label }, + movies, tvShows, + movieWindowDays: movieResult.usedWindow, + tvWindowDays: tvResult.usedWindow, + }; + cacheSet(cacheKey, result, 6 * 60 * 60 * 1000); + res.json(result); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// ── Cache leeren für einen Provider (Admin) ────────────────────────────────── +router.post('/streaming/:providerId/clear-cache', authenticate, requireAdmin, (req, res) => { + const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); + if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); + cacheDelete(`streaming-top10:${provider.id}`); + cacheDelete(`streaming-new:${provider.id}`); + res.json({ ok: true, provider: provider.id }); +}); + +router.get('/streaming/:providerId', authenticate, async (req, res) => { + try { + const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); + if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); + + const streamCacheKey = `streaming-top10:${provider.id}`; + const cachedResult = cacheGet(streamCacheKey); + if (cachedResult !== undefined) return res.json(cachedResult); + + // Nur Titel der letzten 2 Jahre (aktuelles Jahr - 1) + // Für kleine Kataloge (HBO Max, WOW, RTL+) kein Jahresfilter — TMDb hat dort + // zu wenige Titel mit korrekt erfasstem DE-Datum seit letztem Jahr + const SMALL_CATALOGS = new Set(['max']); + const minYear = new Date().getFullYear() - 1; + const minDate = SMALL_CATALOGS.has(provider.id) ? null : `${minYear}-01-01`; + + // Deutsch & Englisch sind unbegrenzt erlaubt. Alle anderen Sprachen + // zusammen (Koreanisch, Japanisch, Spanisch, etc.) dürfen max. 2 Plätze + // in den Top 10 belegen – KEINE Ausnahme mehr für "hohe vote_count", + // weil genau das die Liste mit populären Anime/K-Dramen geflutet hat. + const UNLIMITED_LANGS = new Set(['de', 'en']); + const MAX_OTHER_LANGUAGE_TOTAL = 2; + + // Manche Anbieter (z.B. HBO Max) nutzen bei TMDb DE nicht nur 'flatrate' + const monetizationType = ['max'].includes(provider.id) + ? 'flatrate|free|ads|rent|buy' + : 'flatrate'; + + function buildParams(mediaKind, minVotes, dateFrom) { + return { + watch_region: 'DE', + with_watch_providers: provider.tmdbId, + with_watch_monetization_types: monetizationType, + sort_by: 'popularity.desc', + 'vote_count.gte': minVotes, + ...(dateFrom + ? (mediaKind === 'movie' ? { 'primary_release_date.gte': dateFrom } : { 'first_air_date.gte': dateFrom }) + : {}), + }; + } + + async function fetchPages(mediaKind, minVotes, pageCount, dateFrom) { + const params = buildParams(mediaKind, minVotes, dateFrom); + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const calls = []; + for (let p = 1; p <= pageCount; p++) calls.push(tmdb(path, { ...params, page: p })); + const pages = await Promise.all(calls); + return pages.flatMap(p => p.results || []); + } + + // "Ganz frisch"-Fenster: letzte 90 Tage, sehr niedrige Vote-Schwelle. + // TMDb's popularity-Wert reagiert auf kurzfristige Trend-Spitzen (Suchanfragen, + // Watchlist-Einträge etc.), das geht in der riesigen 2-Jahres-Gesamtliste aber + // unter alten Dauerbrennern unter. Durch die enge Zeitspanne kommen aktuell + // virale Titel (z.B. ein Release dieser Woche) automatisch nach vorne – + // ganz ohne zusätzliche Einzel-Abfragen pro Titel. + const freshDate = (() => { + const d = new Date(); + d.setDate(d.getDate() - 90); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + })(); + + // Robuster Fallback in vier Stufen, garantiert genug Kandidaten: + // 1) hohe Schwelle (letzte 2 Jahre, bekannte Titel) + // 2) mittlere Schwelle (letzte 2 Jahre) + // 3) niedrige Schwelle (letzte 2 Jahre) + // 4) OHNE Jahresfilter (für kleinere Kataloge wie RTL+, die laut TMDb in den + // letzten 2 Jahren kaum neue Lizenzen bekommen haben – die zeigen wir dann + // mit ihren ältesten verfügbaren beliebten Titeln statt einer leeren Liste) + async function fetchCandidates(mediaKind) { + // Schritt 1: ganz frische Titel (letzte 90 Tage, niedrige Schwelle) – + // werden später vorne in die Liste gemischt, damit aktuelle Hits Vorrang haben + const freshRaw = await fetchPages(mediaKind, 5, 1, freshDate); + const fresh = freshRaw.filter(m => !BLOCKED_LANGS.has(m.original_language)); + + // Schritt 2: normaler Pool der letzten 2 Jahre, gestaffelte Vote-Schwelle + let raw = await fetchPages(mediaKind, 300, 2, minDate); + let filtered = raw.filter(m => !BLOCKED_LANGS.has(m.original_language)); + if (filtered.length < 15) { + const more = await fetchPages(mediaKind, 50, 2, minDate); + const seen = new Set(filtered.map(m => m.id)); + for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); } + } + if (filtered.length < 10) { + const more = await fetchPages(mediaKind, 0, 2, minDate); + const seen = new Set(filtered.map(m => m.id)); + for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); } + } + // Schritt 3: kleine Kataloge (z.B. RTL+) – TMDb hat dort oft kaum Titel + // der letzten 2 Jahre korrekt eingetragen, dann ganz ohne Jahresfilter + if (filtered.length < 10) { + const more = await fetchPages(mediaKind, 20, 2, null); + const seen = new Set(filtered.map(m => m.id)); + for (const m of more) if (!seen.has(m.id) && !BLOCKED_LANGS.has(m.original_language)) { filtered.push(m); seen.add(m.id); } + } + + // Frische Titel vorne einmischen (Duplikate raus) + const seenIds = new Set(); + const merged = []; + for (const m of fresh) { if (!seenIds.has(m.id)) { merged.push(m); seenIds.add(m.id); } } + for (const m of filtered) { if (!seenIds.has(m.id)) { merged.push(m); seenIds.add(m.id); } } + return merged; + } + + const [movieRaw, tvRaw, genreMap] = await Promise.all([ + fetchCandidates('movie'), + fetchCandidates('tv'), + getGenreMap(), + ]); + + // Liste bleibt nach Popularität sortiert. DE/EN unbegrenzt, alle anderen + // Sprachen zusammen maximal 2 Plätze in den Top 10. + function applyLanguageWeighting(rawList) { + let otherCount = 0; + const picked = []; + const overflow = []; + for (const item of rawList) { + const lang = item.original_language || 'xx'; + if (UNLIMITED_LANGS.has(lang)) { + picked.push(item); + } else if (otherCount < MAX_OTHER_LANGUAGE_TOTAL) { + picked.push(item); + otherCount++; + } else { + overflow.push(item); + } + if (picked.length >= 10) break; + } + // Falls weniger als 10 zusammenkommen (z.B. sehr kleiner Katalog), + // mit den nächstbeliebtesten auffüllen – auch über den Sprachdeckel hinaus + for (const item of overflow) { + if (picked.length >= 10) break; + picked.push(item); + } + return picked.slice(0, 10); + } + + const movies = applyLanguageWeighting(movieRaw) + .map((m, i) => ({ ...enrichMovie(m, genreMap, null, i + 1), media_type: 'movie' })); + + const tvShows = applyLanguageWeighting(tvRaw) + .map((m, i) => ({ + id: m.id, media_type: 'tv', rank: i + 1, + title: m.name || m.original_name, + original_title: m.original_name, + release_date: m.first_air_date || '', + poster_path: m.poster_path || '', + genres: (m.genre_ids||[]).map(gid => genreMap[gid]).filter(Boolean), + vote_average: m.vote_average, + original_language: m.original_language, + origin_country: m.origin_country || [], + })); + + const result = { + provider: { id: provider.id, label: provider.label }, + movies, tvShows, + isOfficialData: false, + filterInfo: { minYear, maxOtherLanguageTotal: MAX_OTHER_LANGUAGE_TOTAL }, + }; + cacheSet(streamCacheKey, result, 6 * 60 * 60 * 1000); // 6h Cache + res.json(result); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// ── Favorites Routen (feste VOR :param) ────────────────────────────────────── + +// GET /favorites – eigene Favoriten +router.get('/favorites', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC').all(req.user.id)); +}); + +// GET /favorites/all – Admin: alle User gruppiert +router.get('/favorites/all', authenticate, requireAdmin, (req, res) => { + const favs = db.prepare(` + SELECT f.*, u.username + FROM movie_favorites f + JOIN users u ON u.id = f.user_id + ORDER BY u.username ASC, f.added_at DESC + `).all(); + res.json(favs); +}); + +// POST /favorites/user-notify – User hat quittierte Favoriten gesehen +router.post('/favorites/user-notify', authenticate, (req, res) => { + db.prepare('UPDATE movie_favorites SET user_notified=1 WHERE user_id=? AND acknowledged=1 AND user_notified=0').run(req.user.id); + res.json({ ok: true }); +}); + +// GET /favorites/unacked – unquittierte Zählung (Admin: neue Requests; User: quittierte aber unbestätigte) +router.get('/favorites/unacked', authenticate, (req, res) => { + const isAdmin = req.user.role === 'admin'; + if (isAdmin) { + const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get(); + const newCount = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0 AND admin_seen=0').get(); + res.json({ count: count.n, newCount: newCount.n, isAdmin: true }); + } else { + // User: Favoriten die vom Admin quittiert wurden aber User noch nicht gesehen hat + const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE user_id=? AND acknowledged=1 AND user_notified=0').get(req.user.id); + res.json({ count: count.n, isAdmin: false }); + } +}); + +// POST /favorites/mark-seen – Admin hat die offenen Requests angesehen (noch +// nicht zwingend quittiert) — nimmt ihnen das "neu"-Label im Dashboard +router.post('/favorites/mark-seen', authenticate, (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('UPDATE movie_favorites SET admin_seen=1 WHERE acknowledged=0 AND admin_seen=0').run(); + res.json({ ok: true }); +}); + + + +// ── Quittier-Logik (wiederverwendbar: HTTP-Routen + MQTT-Buttons) ─────────── +function ackFavorite(id) { + const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(id); + if (!fav) return null; + db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id); + try { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id); + if (poCfg?.app_token && poCfg?.user_key) { + const title = '✅ Favorit bestätigt'; + const message = `"${fav.title}" wurde von einem Admin bestätigt.`; + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title, + message, + priority: 0, + }), + }).catch(() => {}); + logPush({ userId: fav.user_id, title, message, priority: 0, source: 'media-favorit' }); + } + } catch {} + return fav; +} + +function ackAllFavorites() { + const toAck = db.prepare('SELECT id, user_id, title FROM movie_favorites WHERE acknowledged=0').all(); + db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run(); + try { + const userIds = [...new Set(toAck.map(f => f.user_id))]; + for (const userId of userIds) { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); + if (!poCfg?.app_token || !poCfg?.user_key) continue; + const userFavs = toAck.filter(f => f.user_id === userId); + const title = '✅ Favoriten bestätigt'; + const message = userFavs.length === 1 + ? `"${userFavs[0].title}" wurde von einem Admin bestätigt.` + : `${userFavs.length} deiner Favoriten wurden von einem Admin bestätigt.`; + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title, + message, + priority: 0, + }), + }).catch(() => {}); + logPush({ userId, title, message, priority: 0, source: 'media-favorit' }); + } + } catch {} + return toAck.length; +} + +// POST /favorites/acknowledge-all – Admin: alle quittieren +router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => { + ackAllFavorites(); + res.json({ ok: true }); +}); + +// POST /favorites – Favorit hinzufügen (alle User inkl. Admin bekommen Pushover) +router.post('/favorites', authenticate, (req, res) => { + const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk, media_type } = req.body; + if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' }); + try { + const r = db.prepare(`INSERT OR IGNORE INTO movie_favorites + (user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk,media_type,added_at) + VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) + .run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'', release_date||'', JSON.stringify(genres||[]), fsk||'', media_type||'movie'); + + if (r.changes > 0) { + try { + // Admin-Pushover-Einstellungen aus pushover_settings Tabelle + const adminUser = db.prepare("SELECT id FROM users WHERE role='admin' LIMIT 1").get(); + const poCfg = adminUser + ? db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(adminUser.id) + : null; + const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id); + if (poCfg?.app_token && poCfg?.user_key) { + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`, + title: '⭐ Neue Favoriten', + priority: 0, + }), + }).catch(() => {}); + } + } catch {} + } + res.json({ ok: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// DELETE /favorites/:tmdb_id – eigenen Favoriten löschen (jeder User) +router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { + db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?').run(req.user.id, Number(req.params.tmdb_id)); + res.json({ ok: true }); +}); + +// POST /favorites/:id/acknowledge – Admin: einzelnen quittieren +router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => { + const fav = ackFavorite(req.params.id); + if (!fav) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ ok: true }); +}); + +// POST /favorites/:id/unacknowledge – Admin: Quittierung rückgängig machen +router.post('/favorites/:id/unacknowledge', authenticate, requireAdmin, (req, res) => { + const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(req.params.id); + if (!fav) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('UPDATE movie_favorites SET acknowledged=0, acknowledged_at=NULL, user_notified=0, admin_seen=0 WHERE id=?').run(fav.id); + res.json({ ok: true }); + // Pushover an den User — Quittierung zurückgezogen (fire & forget) + try { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id); + if (poCfg?.app_token && poCfg?.user_key) { + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title: '↩ Favorit zurückgezogen', + message: `Die Bestätigung für "${fav.title}" wurde vom Admin zurückgezogen.`, + priority: 0, + }), + }).catch(() => {}); + } + } catch {} +}); + +// Admin: ALLE verfügbaren Watch-Provider für Deutschland live von TMDb holen +// Admin: Streaming-Top10-Cache leeren (z.B. nach Logik-Änderungen) +// Debug: rohe TMDb-Treffer pro Anbieter/Schwelle prüfen +// Debug: Top10 vs Neuerscheinungen direkt vergleichen +router.get('/streaming/debug-compare/:providerId', authenticate, requireAdmin, async (req, res) => { + try { + // Cache umgehen, beide Endpoints intern nochmal frisch ausführen indem wir + // einfach die echten Routen über fetch auf uns selbst aufrufen würde Auth + // brauchen - stattdessen Cache direkt leeren und Original-Handler-Logik + // duplizieren ist zu viel Aufwand für Debug. Einfacher: aktuellen Cache-Inhalt + // (falls vorhanden) UND since-Werte direkt ausgeben. + const top10 = cacheGet(`streaming-top10:${req.params.providerId}`); + const newRel = cacheGet(`streaming-new:${req.params.providerId}`); + res.json({ + top10_cached: !!top10, + new_cached: !!newRel, + top10_tv_titles: (top10?.tvShows || []).map(t => ({ id: t.id, title: t.title, release_date: t.release_date })), + new_tv_titles: (newRel?.tvShows || []).map(t => ({ id: t.id, title: t.title, release_date: t.release_date })), + new_since: newRel?.since, + }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// Debug: Neuerscheinungen-Discover-Query roh prüfen (ohne Filterung danach) +router.get('/streaming/debug-new/:providerId', authenticate, requireAdmin, async (req, res) => { + try { + const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); + if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); + + const since = (() => { + const d = new Date(); + d.setDate(d.getDate() - 21); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + })(); + const today = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + })(); + + async function raw(mediaKind, sortBy) { + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const params = { + watch_region: 'DE', + with_watch_providers: provider.tmdbId, + with_watch_monetization_types: 'flatrate', + sort_by: sortBy, + ...(mediaKind === 'movie' + ? { 'primary_release_date.gte': since, 'primary_release_date.lte': today } + : { 'first_air_date.gte': since, 'first_air_date.lte': today }), + }; + const data = await tmdb(path, { ...params, page: 1 }); + return { + total_results: data.total_results, + sample: (data.results||[]).slice(0,10).map(m => ({ + title: m.title||m.name, lang: m.original_language, + vote_count: m.vote_count, popularity: m.popularity, + date: m.release_date||m.first_air_date, + })), + }; + } + + const [movieByPop, movieByDate, tvByPop, tvByDate] = await Promise.all([ + raw('movie', 'popularity.desc'), + raw('movie', 'primary_release_date.desc'), + raw('tv', 'popularity.desc'), + raw('tv', 'first_air_date.desc'), + ]); + + res.json({ provider: provider.label, since, today, movieByPop, movieByDate, tvByPop, tvByDate }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +router.get('/streaming/debug/:providerId', authenticate, requireAdmin, async (req, res) => { + try { + const provider = STREAMING_PROVIDERS.find(p => p.id === req.params.providerId); + if (!provider) return res.status(404).json({ error: 'Unbekannter Anbieter' }); + const minYear = new Date().getFullYear() - 1; + const minDate = `${minYear}-01-01`; + + async function countFor(mediaKind, minVotes) { + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const params = { + watch_region: 'DE', + with_watch_providers: provider.tmdbId, + with_watch_monetization_types: 'flatrate', + sort_by: 'popularity.desc', + 'vote_count.gte': minVotes, + ...(mediaKind === 'movie' ? { 'primary_release_date.gte': minDate } : { 'first_air_date.gte': minDate }), + }; + const data = await tmdb(path, { ...params, page: 1 }); + return { total_results: data.total_results, total_pages: data.total_pages, sample: (data.results||[]).slice(0,3).map(m=>m.title||m.name) }; + } + + // Auch OHNE Jahresfilter testen, um zu sehen ob der Jahresfilter das Problem ist + async function countNoYearFilter(mediaKind, minVotes) { + const path = mediaKind === 'movie' ? '/discover/movie' : '/discover/tv'; + const params = { + watch_region: 'DE', + with_watch_providers: provider.tmdbId, + with_watch_monetization_types: 'flatrate', + sort_by: 'popularity.desc', + 'vote_count.gte': minVotes, + }; + const data = await tmdb(path, { ...params, page: 1 }); + return { total_results: data.total_results, total_pages: data.total_pages }; + } + + const [movies300, movies50, movies0, moviesNoYear, tv300, tv50, tv0, tvNoYear] = await Promise.all([ + countFor('movie', 300), countFor('movie', 50), countFor('movie', 0), countNoYearFilter('movie', 0), + countFor('tv', 300), countFor('tv', 50), countFor('tv', 0), countNoYearFilter('tv', 0), + ]); + + res.json({ + provider: provider.label, tmdbId: provider.tmdbId, minDate, + movies: { withVotes300: movies300, withVotes50: movies50, withVotes0: movies0, noYearFilter: moviesNoYear }, + tv: { withVotes300: tv300, withVotes50: tv50, withVotes0: tv0, noYearFilter: tvNoYear }, + }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +router.post('/streaming/clear-cache', authenticate, requireAdmin, (req, res) => { + const r = db.prepare("DELETE FROM xrel_cache WHERE cache_key LIKE 'streaming-top10:%' OR cache_key LIKE 'streaming-new:%'").run(); + res.json({ ok: true, cleared: r.changes }); +}); + +router.get('/settings', authenticate, requireAdmin, (req, res) => res.json({ configured: !!getToken() })); +router.put('/settings', authenticate, requireAdmin, (req, res) => { + const { tmdb_token } = req.body; + if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); + db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim()); + genreCache = null; + res.json({ ok: true }); +}); + + +// DEBUG ext_info – nach Test entfernen +router.get('/debug-extinfo', authenticate, async (req, res) => { + const id = req.query.id || ''; + if (!id) return res.json({ error: 'id required' }); + try { + const data = await xrelFetch('/ext_info/info.json', { id, type: 'movie' }); + res.json(data); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +// DEBUG – nach Test entfernen +router.get('/debug-xrel3', authenticate, async (req, res) => { + const title = req.query.title || ''; + const orig = req.query.orig || ''; + const imdb_id = req.query.imdb_id || null; + const log = { title, orig, imdb_id }; + try { + const clean = (s) => normalizeUmlauts(s||'').replace(/[*#!?:]/g,'').trim().toLowerCase(); + const rawQueries = [orig, title].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); + const normQueries = rawQueries.map(q => normalizeUmlauts(q).replace(/[*#!?:]/g,'').trim()) + .filter(q => !rawQueries.map(r => r.replace(/[*#!?:]/g,'').trim()).includes(q)); + log.rawQueries = rawQueries; + log.normQueries = normQueries; + log.allQueries = [...rawQueries, ...normQueries].filter(Boolean).filter((v,i,a) => a.indexOf(v)===i); + + const results = []; + for (const q of log.allQueries) { + try { + const data = await xrelFetch('/search/ext_info.json', { + q: normalizeUmlauts(q).replace(/[*#!?:]/g,''), type:'movie', limit:5 + }); + results.push({ + query: normalizeUmlauts(q).replace(/[*#!?:]/g,''), + hits: (data.results||[]).map(h => ({ id: h.id, title: h.title, alt_title: h.alt_title, uris: h.uris })) + }); + } catch(e) { results.push({ query: q, error: e.message }); } + } + log.searchResults = results; + } catch(e) { log.error = e.message; } + res.json(log); +}); + +// Debug xREL für TV +router.get('/debug-xrel-tv', authenticate, async (req, res) => { + const { title='', orig='', imdb_id='', year='0', id='0' } = req.query; + try { + const clean = s => (s||'').toLowerCase().trim(); + const allQueries = [orig, title].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i); + + // 1. Suche + const searchResults = await Promise.all(allQueries.map(async q => { + try { + const data = await xrelFetch('/search/ext_info.json', { q: q.replace(/[*#!?:]/g,''), type:'tv', limit:10 }); + return { q, results: data?.results||[], total: data?.total||0 }; + } catch(e) { return { q, error: e.message }; } + })); + + // 2. IMDb-Match + let extInfoId = null; + if (imdb_id) { + for (const r of searchResults) { + const hit = (r.results||[]).find(h => (h.uris||[]).includes(`imdb:${imdb_id}`)); + if (hit) { extInfoId = hit.id; break; } + } + } + + // 3. Titel-Match + if (!extInfoId) { + for (const r of searchResults) { + for (const h of r.results||[]) { + if (clean(h.title)===clean(orig)||clean(h.title)===clean(title)|| + clean(h.alt_title)===clean(orig)||clean(h.alt_title)===clean(title)) { + extInfoId = h.id; break; + } + } + if (extInfoId) break; + } + } + + // 4. Releases wenn gefunden + let releases = []; + if (extInfoId) { + const [scene, p2p] = await Promise.all([ + xrelFetch('/release/ext_info.json', { id: extInfoId, per_page: 25 }).catch(()=>({})), + xrelFetch('/p2p/releases.json', { ext_info_id: extInfoId, per_page: 25 }).catch(()=>({})), + ]); + releases = [ + ...(scene.list||[]).map(r=>({ src:'scene', dirname:r.dirname, german:/german|deutsch/i.test(r.dirname||'') })), + ...(p2p.list||[]).map(r=>({ src:'p2p', dirname:r.dirname, german: r.main_lang==='german' })), + ]; + } + + res.json({ searchResults, extInfoId, releases: releases.slice(0,20) }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +module.exports = router; +module.exports.ackFavorite = ackFavorite; +module.exports.ackAllFavorites = ackAllFavorites; diff --git a/backend/src/tools/nachrichten/routes.js b/backend/src/tools/nachrichten/routes.js new file mode 100644 index 0000000..c170973 --- /dev/null +++ b/backend/src/tools/nachrichten/routes.js @@ -0,0 +1,216 @@ +const express = require('express'); +const db = require('../../db'); +const { logPush } = require('../../pushLog'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── Pushover Helper ─────────────────────────────────────────────────────────── +async function sendPushover(userKey, appToken, title, message, opts = {}, userId = null) { + let priority = 0; + try { + const params = { token: appToken, user: userKey, title, message }; + if (opts.retry && opts.expire) { + params.priority = 2; + priority = 2; + params.retry = opts.retry; + params.expire = opts.expire; + } + await fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + logPush({ userId, title, message, priority, source: 'nachrichten' }); + } catch { + logPush({ userId, title, message, priority, source: 'nachrichten', success: false }); + } +} + +// ── Public Keys ─────────────────────────────────────────────────────────────── +router.get('/keys/:userId', authenticate, (req, res) => { + const row = db.prepare('SELECT public_key FROM user_keys WHERE user_id = ?').get(req.params.userId); + res.json({ public_key: row?.public_key || null }); +}); + +router.post('/keys', authenticate, (req, res) => { + const { public_key } = req.body; + if (!public_key) return res.status(400).json({ error: 'Kein Schlüssel' }); + db.prepare(` + INSERT INTO user_keys (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET public_key=excluded.public_key, updated_at=CURRENT_TIMESTAMP + `).run(req.user.id, public_key); + res.json({ ok: true }); +}); + +// ── Users ───────────────────────────────────────────────────────────────────── +// Presence: User ist gerade aktiv im Chat +router.post('/presence', authenticate, (req, res) => { + const { active } = req.body; + if (active) { + db.prepare("UPDATE users SET chat_active_at=datetime('now','localtime') WHERE id=?").run(req.user.id); + } else { + db.prepare("UPDATE users SET chat_active_at=NULL WHERE id=?").run(req.user.id); + } + res.json({ ok: true }); +}); + +router.get('/users', authenticate, (req, res) => { + const me = req.user.id; + const isAdmin = req.user.role === 'admin'; + + // Admins sehen alle. Normale User sehen: + // - keine hidden User + // - wenn sie selbst hidden sind: nur Admins + const meUser = db.prepare('SELECT hidden FROM users WHERE id=?').get(me); + const isMeHidden = !!meUser?.hidden; + + let whereExtra = ''; + if (!isAdmin && isMeHidden) { + // Ich bin hidden → sehe nur Admins + whereExtra = "AND u.role = 'admin'"; + } else if (!isAdmin) { + // Normaler User → sieht keine hidden User + whereExtra = "AND (u.hidden = 0 OR u.hidden IS NULL)"; + } + // Admin → kein Filter + + const users = db.prepare(` + SELECT u.id, u.username, uk.public_key, + (SELECT COUNT(*) FROM messages + WHERE sender_id = u.id AND recipient_id = ? AND read_by_recipient = 0) AS unread, + (SELECT MAX(created_at) FROM messages + WHERE (sender_id = u.id AND recipient_id = ?) + OR (sender_id = ? AND recipient_id = u.id)) AS last_message_at + FROM users u + LEFT JOIN user_keys uk ON uk.user_id = u.id + WHERE u.id != ? ${whereExtra} + ORDER BY last_message_at DESC, u.username ASC + `).all(me, me, me, me); + res.json(users); +}); + +// ── Unread Count ────────────────────────────────────────────────────────────── +router.get('/unread', authenticate, (req, res) => { + const row = db.prepare( + 'SELECT COUNT(*) AS count FROM messages WHERE recipient_id = ? AND read_by_recipient = 0' + ).get(req.user.id); + res.json({ count: row.count }); +}); + +// ── Messages ────────────────────────────────────────────────────────────────── +router.get('/messages/:userId', authenticate, (req, res) => { + const me = req.user.id; + const other = parseInt(req.params.userId); + + db.prepare(` + UPDATE messages SET read_by_recipient = 1 + WHERE recipient_id = ? AND sender_id = ? AND read_by_recipient = 0 + `).run(me, other); + + const msgs = db.prepare(` + SELECT id, sender_id, encrypted_content, iv, created_at, + (sender_id = ?) AS is_mine, + read_by_recipient + FROM messages + WHERE (sender_id = ? AND recipient_id = ?) + OR (recipient_id = ? AND sender_id = ?) + ORDER BY created_at ASC + `).all(me, me, other, me, other); + + res.json(msgs); +}); + +router.post('/messages/:userId', authenticate, async (req, res) => { + const me = req.user.id; + const recipId = parseInt(req.params.userId); + const { encrypted_content, iv } = req.body; + if (!encrypted_content || !iv) return res.status(400).json({ error: 'Fehlende Felder' }); + + const recipient = db.prepare('SELECT id FROM users WHERE id = ?').get(recipId); + if (!recipient) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + + const result = db.prepare( + "INSERT INTO messages (sender_id, recipient_id, encrypted_content, iv, created_at) VALUES (?, ?, ?, ?, datetime('now', 'localtime'))" + ).run(me, recipId, encrypted_content, iv); + + const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand'; + const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId); + if (pushover) { + // Kein Push wenn Empfänger gerade im Chat ist (aktiv in letzten 45 Sekunden) + const presence = db.prepare(` + SELECT chat_active_at FROM users WHERE id=? AND chat_active_at > datetime('now','localtime','-45 seconds') + `).get(recipId); + if (!presence) { + await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`, + { retry: pushover.retry, expire: pushover.expire }, recipId); + } + } + + res.json({ id: result.lastInsertRowid }); +}); + +// DELETE einzelne Nachricht – hard delete, beide Seiten dürfen löschen +router.delete('/messages/:messageId', authenticate, (req, res) => { + const me = req.user.id; + const mid = parseInt(req.params.messageId); + db.prepare( + 'DELETE FROM messages WHERE id = ? AND (sender_id = ? OR recipient_id = ?)' + ).run(mid, me, me); + res.json({ ok: true }); +}); + +// DELETE gesamte Konversation – hard delete +router.delete('/conversation/:userId', authenticate, (req, res) => { + const me = req.user.id; + const other = parseInt(req.params.userId); + db.prepare(` + DELETE FROM messages + WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?) + `).run(me, other, other, me); + res.json({ ok: true }); +}); + +// DELETE alle eigenen Nachrichten (für Schlüssel-Reset) +router.delete('/my-messages', authenticate, (req, res) => { + db.prepare('DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?') + .run(req.user.id, req.user.id); + res.json({ ok: true }); +}); + +// ── Pushover Settings ───────────────────────────────────────────────────────── +router.get('/pushover', authenticate, (req, res) => { + const row = db.prepare('SELECT user_key, app_token, retry, expire FROM pushover_settings WHERE user_id = ?').get(req.user.id); + res.json(row || { user_key: '', app_token: '', retry: null, expire: null }); +}); + +router.post('/pushover', authenticate, (req, res) => { + const { user_key, app_token, retry, expire } = req.body; + if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' }); + db.prepare(` + INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token, + retry=excluded.retry, expire=excluded.expire + `).run(req.user.id, user_key, app_token, retry || null, expire || null); + res.json({ ok: true }); +}); + +router.delete('/pushover', authenticate, (req, res) => { + db.prepare('DELETE FROM pushover_settings WHERE user_id = ?').run(req.user.id); + res.json({ ok: true }); +}); + +router.post('/pushover/test', authenticate, async (req, res) => { + const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id); + if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' }); + const useEmergency = req.body?.emergency === true && row.retry && row.expire; + const msg = useEmergency + ? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.` + : 'Pushover-Verbindung erfolgreich! 🎉'; + await sendPushover(row.user_key, row.app_token, 'DickenDock', msg, + useEmergency ? { retry: row.retry, expire: row.expire } : {}, req.user.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/paywallkiller/routes.js b/backend/src/tools/paywallkiller/routes.js new file mode 100644 index 0000000..9e41680 --- /dev/null +++ b/backend/src/tools/paywallkiller/routes.js @@ -0,0 +1,5 @@ +const express = require('express'); +const router = express.Router(); +// Paywall-Killer: gesamter Flow läuft im Browser des Users. +// Kein aktiver Backend-Endpoint nötig. +module.exports = router; diff --git a/backend/src/tools/qrcodes/routes.js b/backend/src/tools/qrcodes/routes.js new file mode 100644 index 0000000..c18cff8 --- /dev/null +++ b/backend/src/tools/qrcodes/routes.js @@ -0,0 +1,41 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +router.get('/', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM qr_codes WHERE user_id=? ORDER BY created_at DESC').all(req.user.id)); +}); + +router.post('/', authenticate, (req, res) => { + const uid = req.user.id; + const { label='', url, size=256, fg_color='#000000', bg_color='#ffffff', margin=4, + dot_style='square', corner_style='square', caption='', caption_pos='bottom', + caption_color='#000000', caption_size=14 } = req.body; + if (!url?.trim()) return res.status(400).json({ error: 'URL erforderlich' }); + const r = db.prepare(` + INSERT INTO qr_codes (user_id,label,url,size,fg_color,bg_color,margin,dot_style,corner_style,caption,caption_pos,caption_color,caption_size,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + `).run(uid, label.trim(), url.trim(), size, fg_color, bg_color, margin, dot_style, corner_style, caption.trim(), caption_pos, caption_color, caption_size); + res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/:id', authenticate, (req, res) => { + const qr = db.prepare('SELECT * FROM qr_codes WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!qr) return res.status(404).json({ error: 'Nicht gefunden' }); + const fields = ['label','url','size','fg_color','bg_color','margin','dot_style','corner_style','caption','caption_pos','caption_color','caption_size']; + const updates = {}; + fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; }); + if (!Object.keys(updates).length) return res.json(qr); + const sets = Object.keys(updates).map(f=>`${f}=?`).join(','); + db.prepare(`UPDATE qr_codes SET ${sets} WHERE id=?`).run(...Object.values(updates), qr.id); + res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(qr.id)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM qr_codes WHERE id=? AND user_id=?').run(req.params.id, req.user.id); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/schocken/routes.js b/backend/src/tools/schocken/routes.js new file mode 100644 index 0000000..a758566 --- /dev/null +++ b/backend/src/tools/schocken/routes.js @@ -0,0 +1,609 @@ +const express = require('express'); +const db = require('../../db'); +const { logPush } = require('../../pushLog'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS schocken_games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + players TEXT NOT NULL, -- JSON: [{id, username, order}] + status TEXT NOT NULL DEFAULT 'lobby', + -- status: lobby | half1 | half2 | endkampf | finished + phase_status TEXT NOT NULL DEFAULT 'playing', + -- phase_status: playing | finished + stock INTEGER NOT NULL DEFAULT 15, -- Scheiben auf dem Stock + player_chips TEXT NOT NULL DEFAULT '{}', -- JSON: {userId: chipCount} + has_16th INTEGER, -- userId der die 16. Scheibe hat + loser_h1 INTEGER, -- userId Verlierer Hälfte 1 + loser_h2 INTEGER, -- userId Verlierer Hälfte 2 + current_round TEXT, -- JSON: aktueller Rundenstand + round_number INTEGER NOT NULL DEFAULT 1, + beginner_id INTEGER, -- wer fängt die Runde an + current_player_idx INTEGER NOT NULL DEFAULT 0, + max_rolls INTEGER, -- vom Beginner bestimmt + first_round INTEGER NOT NULL DEFAULT 1, -- 1 = erste Runde der Hälfte + drink_losses TEXT NOT NULL DEFAULT '{}', -- {userId: count} + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + `); +})(); + +// ── Würfellogik ─────────────────────────────────────────────────────────────── +function rollDice(count = 3) { + return Array.from({ length: count }, () => Math.floor(Math.random() * 6) + 1); +} + +// Würfel klassifizieren +function classifyDice(dice) { + const sorted = [...dice].sort((a,b) => a-b); + const [a,b,c] = sorted; + + // Schock aus: 1-1-1 + if (a===1 && b===1 && c===1) return { type:'schock_aus', scheiben: 0, label:'Schock aus! 💀' }; + + // Julchen: 4-2-1 (unsortiert erkannt) + if (dice.includes(1) && dice.includes(2) && dice.includes(4)) return { type:'julchen', scheiben:7, label:'Julchen! 🎉' }; + + // General: 3x gleich (außer 3x1) + if (a===b && b===c) return { type:'general', scheiben:3, label:`General ${a}er! 🎲`, value: a }; + + // Schock: 2x Eins + andere + if (a===1 && b===1) { + const schockVal = c; + const label = schockVal === 2 ? 'Schock doof 😅' : `Schock ${schockVal}!`; + return { type:'schock', scheiben: schockVal, label, value: schockVal }; + } + + // Straße: aufeinanderfolgende Zahlen (nur wenn alle 3 auf einmal gewürfelt) + if (c-a===2 && b-a===1) return { type:'strasse', scheiben:2, label:`Straße ${a}-${b}-${c}!`, value: a }; + + // Normale Zahl + // Normale Zahlen: absteigend sortiert als dreistellige Zahl (z.B. 6-2-1 = 621, 5-5-4 = 554) + const desc = [...sorted].reverse(); // absteigend + const normalValue = desc[0]*100 + desc[1]*10 + desc[2]; + return { type:'normal', scheiben:1, label:`${desc[0]}-${desc[1]}-${desc[2]}`, value: normalValue }; +} + +// Wertungsvergleich: wer hat schlechtere Würfel (bekommt Scheiben) +// Gibt -1 wenn a schlechter, 1 wenn b schlechter, 0 bei echtem Gleichstand +const TYPE_ORDER = ['normal','strasse','general','schock','julchen','schock_aus']; +function compareResults(a, b) { + const ai = TYPE_ORDER.indexOf(a.result.type); + const bi = TYPE_ORDER.indexOf(b.result.type); + if (ai !== bi) return ai < bi ? -1 : 1; // niedrigerer Typ = schlechter + + // Gleicher Typ — Detailvergleich + if (a.result.type === 'normal') { + // Niedrigere Augensumme = schlechter + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + if (a.result.type === 'strasse') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + if (a.result.type === 'schock') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + if (a.result.type === 'general') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + return 0; +} + +function sendPush(userId, title, message) { + const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); + if (!cfg?.app_token || !cfg?.user_key) return; + fetch('https://api.pushover.net/1/messages.json', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), + }).catch(() => {}); + logPush({ userId, title, message, priority: 0, source: 'schocken' }); +} + +const uid = req => { + // Testmodus: Admin kann als anderen User agieren + const testAs = req.headers['x-schocken-test-as']; + if (testAs && req.user?.role === 'admin') { + const testId = parseInt(testAs); + if (!isNaN(testId)) return testId; + } + return req.user.id; +}; + +// ── Alle Spiele des Users ───────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + const games = db.prepare(` + SELECT * FROM schocken_games + WHERE players LIKE ? ORDER BY updated_at DESC LIMIT 20 + `).all(`%"id":${me}%`); + res.json(games.map(g => ({ + ...g, + players: JSON.parse(g.players), + player_chips: JSON.parse(g.player_chips), + drink_losses: JSON.parse(g.drink_losses), + current_round: g.current_round ? JSON.parse(g.current_round) : null, + }))); +}); + +// ── Users für Einladen ──────────────────────────────────────────────────────── +router.get('/users', authenticate, (req, res) => { + const me = uid(req); + const isAdmin = req.user?.role === 'admin'; + const users = isAdmin + ? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me) + : db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me); + res.json(users); +}); + +// ── Einzelnes Spiel ─────────────────────────────────────────────────────────── +router.get('/:id', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + const players = JSON.parse(game.players); + if (!players.find(p => p.id === me)) return res.status(403).json({ error: 'Kein Zugriff' }); + res.json({ + ...game, + players, + player_chips: JSON.parse(game.player_chips), + drink_losses: JSON.parse(game.drink_losses), + current_round: game.current_round ? JSON.parse(game.current_round) : null, + }); +}); + +// ── Neues Spiel erstellen ───────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const me = uid(req); + const { player_ids = [] } = req.body; + if (player_ids.length < 1) return res.status(400).json({ error: 'Mindestens 1 weiterer Spieler' }); + + const allIds = [me, ...player_ids.filter(id => id !== me)]; + const userRows = db.prepare(`SELECT id, username FROM users WHERE id IN (${allIds.map(()=>'?').join(',')}) ORDER BY username`).all(...allIds); + + // Reihenfolge: Ersteller zuerst, dann die anderen + const ordered = [ + userRows.find(u => u.id === me), + ...allIds.slice(1).map(id => userRows.find(u => u.id === id)).filter(Boolean), + ]; + const players = ordered.map((u, i) => ({ id: u.id, username: u.username, order: i })); + const playerChips = Object.fromEntries(players.map(p => [p.id, 0])); + const drinkLosses = Object.fromEntries(players.map(p => [p.id, 0])); + + const result = db.prepare(` + INSERT INTO schocken_games (players, stock, player_chips, drink_losses, beginner_id, status) + VALUES (?, 15, ?, ?, ?, 'lobby') + `).run(JSON.stringify(players), JSON.stringify(playerChips), JSON.stringify(drinkLosses), me); + + // Pushover an andere Spieler + const myName = ordered[0].username; + for (const p of players.slice(1)) { + sendPush(p.id, '🎲 Schocken', `${myName} lädt dich zu einer Runde Schocken ein!`); + } + + res.json({ id: result.lastInsertRowid }); +}); + +// ── Spiel starten (Lobby → Hälfte 1) ───────────────────────────────────────── +router.post('/:id/start', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + const players = JSON.parse(game.players); + if (players[0].id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann starten' }); + if (game.status !== 'lobby') return res.status(400).json({ error: 'Spiel läuft schon' }); + + db.prepare(`UPDATE schocken_games SET status='half1', first_round=1, beginner_id=?, + current_player_idx=0, updated_at=datetime('now','localtime') WHERE id=?`).run(me, game.id); + + for (const p of players.slice(1)) { + sendPush(p.id, '🎲 Schocken', 'Das Spiel beginnt! Du bist dran.'); + } + + res.json(getGame(game.id, me)); +}); + +// ── Würfeln ─────────────────────────────────────────────────────────────────── +router.post('/:id/roll', authenticate, (req, res) => { + const me = uid(req); + const { keep_dice = [], flip_sixes = false, go_dark = false } = req.body; + // keep_dice: array of indices (0-2) der Würfel die stehen bleiben + // flip_sixes: zwei Sechsen zu Eins umdrehen + // go_dark: dunkel legen nach diesem Wurf + + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const activePlayers = getActivePlayers(game); + const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length]; + + if (!currentPlayer || currentPlayer.id !== me) + return res.status(400).json({ error: 'Nicht dein Zug' }); + + let round = game.current_round ? JSON.parse(game.current_round) : initRound(game, activePlayers); + const myState = round.playerStates[me]; + + if (myState.done) return res.status(400).json({ error: 'Du hast schon gewürfelt' }); + if (myState.dark) return res.status(400).json({ error: 'Du bist dunkel' }); + + // Flip sixes: nur wenn noch Würfe übrig und nicht letzter Wurf + if (flip_sixes) { + const sixes = myState.dice.reduce((acc, v, i) => v === 6 ? [...acc, i] : acc, []); + if (sixes.length >= 2) { + myState.dice[sixes[0]] = 1; + myState.dice[sixes[1]] = 1; + // Kostet keinen Wurf, braucht aber noch einen + } + } + + // Würfeln + const rollCount = myState.roll_count; + const isFirstRound = !!game.first_round; + + // Neue Würfel für nicht-stehen-gelassene + if (!isFirstRound || rollCount === 0) { + const newDice = rollDice(3 - keep_dice.length); + let newIdx = 0; + for (let i = 0; i < 3; i++) { + if (!keep_dice.includes(i)) myState.dice[i] = newDice[newIdx++]; + } + } + + myState.roll_count++; + + // Straße nur gültig wenn im ersten Wurf (keine stehen gelassenen Würfel) + const classified = classifyDice(myState.dice); + if (classified.type === 'strasse' && (keep_dice.length > 0 || myState.roll_count > 1)) { + // Straße ungültig — als normal werten + classified.type = 'normal'; + classified.scheiben = 1; + classified.label = 'Normal (Straße ungültig)'; + } + + // Max rolls durch Beginner bestimmt + const maxRolls = round.max_rolls || 3; + const isDark = go_dark || myState.roll_count >= maxRolls || isFirstRound; + const isDone = isDark || myState.roll_count >= maxRolls || isFirstRound; + + myState.dark = go_dark || (myState.roll_count >= maxRolls && !isDone) || isFirstRound; + myState.done = isDone; + myState.result = isDone && !go_dark ? classified : null; // bei dunkel: result erst am Ende + + // Wenn Beginner (erster Spieler): max_rolls setzen + if (currentPlayer.id === round.beginner_id && round.max_rolls === null) { + round.max_rolls = myState.roll_count; + } + + // Nächsten Spieler + const nextIdx = (game.current_player_idx + 1) % activePlayers.length; + const allDone = activePlayers.every(p => round.playerStates[p.id]?.done); + + round.playerStates[me] = myState; + + if (allDone) { + // Alle dunkel aufdecken + for (const p of activePlayers) { + const ps = round.playerStates[p.id]; + if (!ps.result) { + ps.result = classifyDice(ps.dice); + // Straße bei dunkel-legen auch ungültig wenn keep_dice vorhanden (nicht trackbar nachträglich) + } + } + round.phase = 'reveal'; + } + + db.prepare(`UPDATE schocken_games SET current_round=?, current_player_idx=?, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(round), allDone ? game.current_player_idx : nextIdx, game.id); + + // Pushover an nächsten Spieler + if (!allDone) { + const next = activePlayers[nextIdx]; + if (next && next.id !== me) { + sendPush(next.id, '🎲 Schocken', `${currentPlayer.username} hat gewürfelt — du bist dran!`); + } + } + + res.json(getGame(game.id, me)); +}); + +// ── Runde auswerten ─────────────────────────────────────────────────────────── +router.post('/:id/evaluate', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const activePlayers = getActivePlayers(game); + if (players[0].id !== me && activePlayers[0]?.id !== me) + return res.status(403).json({ error: 'Kein Zugriff' }); + + const round = JSON.parse(game.current_round); + if (round.phase !== 'reveal') return res.status(400).json({ error: 'Noch nicht alle fertig' }); + + const playerChips = JSON.parse(game.player_chips); + let stock = game.stock; + + // Schock aus prüfen + const schockAus = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus'); + if (schockAus) { + return handleSchockAus(game, round, activePlayers, res, me); + } + + // Bestes Ergebnis finden (höchster Typ) + const results = activePlayers.map(p => ({ + player: p, + state: round.playerStates[p.id], + result: round.playerStates[p.id]?.result, + rolls: round.playerStates[p.id]?.roll_count || 1, + })).filter(r => r.result); + + // Schlechtestes Ergebnis = bekommt Scheiben + results.sort((a, b) => { + const cmp = compareResults(a, b); + if (cmp !== 0) return cmp; // -1 wenn a schlechter + // Gleicher Typ: mehr Würfe = schlechter (vorne in der Liste = Index 0 = Verlierer) + if (a.rolls !== b.rolls) return a.rolls - b.rolls; // mehr Würfe = schlechter (vorne) + // Nachleger: später in Reihenfolge = schlechter (vorne) + return activePlayers.indexOf(a.player) - activePlayers.indexOf(b.player); + }); + + const loser = results[0]; // schlechtestes Ergebnis (Index 0 nach sort) + const winner = results[results.length - 1]; // bestes Ergebnis + // Scheiben die verteilt werden = Wert des BESTEN Ergebnisses der Runde + const scheiben = winner.result.scheiben; + + // Scheiben verteilen + let fromStock = 0; + let newStock = stock; + if (stock >= scheiben) { + fromStock = scheiben; + newStock = stock - scheiben; + } else { + fromStock = stock; + newStock = 0; + } + + const fromOthers = scheiben - fromStock; + playerChips[loser.player.id] = (playerChips[loser.player.id] || 0) + scheiben; + + // Wenn vom Stock nicht genug: Rest von aktivsten Spielern nehmen (noch nicht implementiert vereinfacht) + if (fromOthers > 0) { + // Scheiben von anderen Spielern umverteilen (von denen mit meisten Scheiben) + const donors = activePlayers + .filter(p => p.id !== loser.player.id && playerChips[p.id] > 0) + .sort((a,b) => playerChips[b.id] - playerChips[a.id]); + let remaining = fromOthers; + for (const donor of donors) { + if (remaining <= 0) break; + const take = Math.min(playerChips[donor.id], remaining); + playerChips[donor.id] -= take; + remaining -= take; + } + } + + // Hälfte/Endkampf vorbei? Prüfen ob Stock leer und nur einer Scheiben hat + const playersWithChips = activePlayers.filter(p => playerChips[p.id] > 0); + const phaseOver = newStock === 0 && playersWithChips.length <= 1; + + let newStatus = game.status; + let newPhaseStatus = 'playing'; + let loserH1 = game.loser_h1; + let loserH2 = game.loser_h2; + let has16th = game.has_16th; + let eventMsg = null; + + if (phaseOver) { + const phaseLoser = playersWithChips[0] || loser.player; + + if (game.status === 'half1') { + // Verlierer H1 bestimmt + loserH1 = phaseLoser.id; + has16th = phaseLoser.id; + newStatus = 'half2'; + // Chips reset, Stock reset + Object.keys(playerChips).forEach(k => { playerChips[k] = 0; }); + newStock = 15; + eventMsg = `${phaseLoser.username} hat die erste Hälfte verloren. Die erste Hälfte kostet nur Nerven! 😅`; + // Pushover + for (const p of players) { + sendPush(p.id, '🎲 Schocken', eventMsg); + } + } else if (game.status === 'half2') { + loserH2 = phaseLoser.id; + if (loserH1 === loserH2) { + // Doppelfeige! + newStatus = 'finished'; + newPhaseStatus = 'finished'; + const dl = JSON.parse(game.drink_losses); + dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1; + eventMsg = `${phaseLoser.username} ist DOPPELFEIGE! 🪶 Die Runde ist vorbei.`; + db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, has_16th=?, + player_chips=?, stock=?, drink_losses=?, current_round=NULL, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(newStatus, newPhaseStatus, loserH2, has16th, JSON.stringify(playerChips), + newStock, dl, game.id); + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + return res.json(getGame(game.id, me)); + } + // Endkampf + newStatus = 'endkampf'; + Object.keys(playerChips).forEach(k => { playerChips[k] = 0; }); + newStock = 15; + eventMsg = `${phaseLoser.username} verliert die zweite Hälfte! Endkampf: ${players.find(p=>p.id===loserH1)?.username} vs ${phaseLoser.username}`; + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + } else if (game.status === 'endkampf') { + newStatus = 'finished'; + newPhaseStatus = 'finished'; + const dl = JSON.parse(game.drink_losses); + dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1; + eventMsg = `${phaseLoser.username} hat den Endkampf verloren und muss die nächste Runde zahlen! 🍺`; + db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, + player_chips=?, stock=?, drink_losses=?, current_round=NULL, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(newStatus, newPhaseStatus, loserH2, JSON.stringify(playerChips), newStock, JSON.stringify(dl), game.id); + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + return res.json(getGame(game.id, me)); + } + } + + // Nächste Runde vorbereiten + const nextBeginner = loser.player; + db.prepare(`UPDATE schocken_games SET player_chips=?, stock=?, status=?, loser_h1=?, loser_h2=?, + has_16th=?, beginner_id=?, current_round=NULL, current_player_idx=0, first_round=0, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(playerChips), newStock, newStatus, loserH1, loserH2, + has16th, nextBeginner.id, game.id); + + // Pushover an Verlierer der Runde (er fängt an) + sendPush(loser.player.id, '🎲 Schocken', `Du hast die Runde verloren und fängst die nächste an! (${scheiben} Scheibe${scheiben!==1?'n':''})`); + + res.json({ ...getGame(game.id, me), round_result: { loser: loser.player, scheiben, eventMsg } }); +}); + +// ── Becher umdrehen (Runde bereit signalisieren) ────────────────────────────── +router.post('/:id/ready', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + const activePlayers = getActivePlayers(game); + + // Spieler der eigentlich raus wäre aber trotzdem ready drückt → wird wieder aktiv (Falle!) + const isActuallyActive = activePlayers.find(p => p.id === me); + const isOut = !isActuallyActive && playerChips[me] === 0 && game.stock === 0; + + let round = game.current_round ? JSON.parse(game.current_round) : null; + if (!round) { + round = initRound(game, isOut ? [...activePlayers, players.find(p=>p.id===me)] : activePlayers); + } + + round.ready = round.ready || {}; + round.ready[me] = true; + + // Wenn isOut und drückt trotzdem: wird als "gefangen" markiert + if (isOut) { + round.trapped = round.trapped || []; + if (!round.trapped.includes(me)) round.trapped.push(me); + } + + db.prepare(`UPDATE schocken_games SET current_round=?, updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(round), game.id); + + res.json(getGame(game.id, me)); +}); + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── +function getActivePlayers(game) { + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + const stock = game.stock; + + if (game.status === 'endkampf') { + return players.filter(p => p.id === game.loser_h1 || p.id === game.loser_h2); + } + + // Wenn Stock noch voll oder erste Runde: alle mitspielen + if (stock > 0) return players; + + // Stock leer: nur Spieler mit Scheiben + return players.filter(p => playerChips[p.id] > 0); +} + +function initRound(game, activePlayers) { + const playerStates = {}; + for (const p of activePlayers) { + playerStates[p.id] = { + dice: [null, null, null], + roll_count: 0, + done: false, + dark: false, + result: null, + }; + } + return { + phase: 'rolling', + beginner_id: game.beginner_id, + max_rolls: game.first_round ? 1 : null, + ready: {}, + trapped: [], + playerStates, + }; +} + +function handleSchockAus(game, round, activePlayers, res, me) { + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + + // Alle Scheiben (Stock + Spieler) → schlechtester Spieler + const allChips = Object.values(playerChips).reduce((s,v) => s+v, 0) + game.stock; + + // Wer hat die wenigsten Punkte (außer dem der Schock aus hat)? + const schockAusPlayer = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus'); + const others = activePlayers.filter(p => p.id !== schockAusPlayer?.id); + + // Vereinfacht: Schock aus Spieler hat gewonnen, alle Scheiben gehen an den schlechtesten der anderen + // (Schock aus steht über allem) + const newChips = Object.fromEntries(Object.keys(playerChips).map(k => [k, 0])); + + // Verlierer = wer die wenigsten Punkte hat unter den anderen + // Bei nur einem anderen: der verliert + const phaseLoser = others.length > 0 ? others[others.length - 1] : schockAusPlayer; + newChips[phaseLoser.id] = allChips; + + let newStatus = game.status; + let loserH1 = game.loser_h1; + let has16th = game.has_16th; + + if (game.status === 'half1') { + loserH1 = phaseLoser.id; + has16th = phaseLoser.id; + newStatus = 'half2'; + Object.keys(newChips).forEach(k => { newChips[k] = 0; }); + const eventMsg = `SCHOCK AUS! ${phaseLoser.username} verliert die erste Hälfte! Die erste Hälfte kostet nur Nerven! 😅`; + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + db.prepare(`UPDATE schocken_games SET player_chips=?, stock=15, status=?, loser_h1=?, has_16th=?, + beginner_id=?, current_round=NULL, current_player_idx=0, first_round=1, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(newChips), newStatus, loserH1, has16th, phaseLoser.id, game.id); + } + + res.json({ ...getGame(game.id, me), schock_aus: true, phase_loser: phaseLoser }); +} + +function getGame(id, requesterId) { + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(id); + if (!game) return null; + return { + ...game, + players: JSON.parse(game.players), + player_chips: JSON.parse(game.player_chips), + drink_losses: JSON.parse(game.drink_losses), + current_round: game.current_round ? JSON.parse(game.current_round) : null, + }; +} + +// ── Spiel abbrechen (Admin only) — wird komplett gelöscht ─────────────────── +router.post('/:id/cancel', authenticate, (req, res) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Nur Admin' }); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + const players = JSON.parse(game.players); + db.prepare('DELETE FROM schocken_games WHERE id=?').run(game.id); + for (const p of players) { + sendPush(p.id, '🎲 Schocken', 'Das Spiel wurde vom Admin abgebrochen.'); + } + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/snippets/routes.js b/backend/src/tools/snippets/routes.js new file mode 100644 index 0000000..8897a4c --- /dev/null +++ b/backend/src/tools/snippets/routes.js @@ -0,0 +1,142 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +const uid = req => req.user.id; + +const getTags = id => db.prepare('SELECT tag FROM snippet_tags WHERE snippet_id=?').all(id).map(r=>r.tag); + +const enrich = (s) => ({ ...s, tags: getTags(s.id) }); + +// ── Alle Snippets (eigene + geteilt) ──────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + const own = db.prepare(` + SELECT s.*, u.username as owner + FROM snippets s JOIN users u ON u.id=s.user_id + WHERE s.user_id=? ORDER BY s.updated_at DESC, s.created_at DESC + `).all(me).map(enrich); + + const shared = db.prepare(` + SELECT s.*, u.username as owner, 1 as is_shared + FROM snippets s + JOIN snippet_shares sh ON sh.snippet_id=s.id + JOIN users u ON u.id=s.user_id + WHERE sh.shared_with=? + ORDER BY s.updated_at DESC + `).all(me).map(enrich); + + const sharedByMe = db.prepare(` + SELECT s.*, u.username as shared_with_name + FROM snippets s + JOIN snippet_shares sh ON sh.snippet_id=s.id + JOIN users u ON u.id=sh.shared_with + WHERE s.user_id=? + ORDER BY s.title ASC + `).all(me).map(enrich); + + res.json({ own, shared, sharedByMe }); +}); + +// ── Einzelnes Snippet ──────────────────────────────────────────────────────── +router.get('/:id', authenticate, (req, res) => { + const s = db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(req.params.id); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + const isOwner = s.user_id === uid(req); + const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req)); + if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' }); + res.json(enrich(s)); +}); + +// ── History ────────────────────────────────────────────────────────────────── +router.get('/:id/history', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=?').get(req.params.id); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + const isOwner = s.user_id === uid(req); + const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req)); + if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' }); + res.json(db.prepare('SELECT * FROM snippet_history WHERE snippet_id=? ORDER BY saved_at DESC').all(s.id)); +}); + +// ── Erstellen ──────────────────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const { title, code='', language='text', description='', tags=[] } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' }); + const r = db.prepare(` + INSERT INTO snippets (user_id, title, code, language, description, created_at, updated_at) + VALUES (?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime')) + `).run(uid(req), title.trim(), code, language, description); + const id = r.lastInsertRowid; + const insertTag = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)'); + db.transaction(()=>{ tags.forEach(t => insertTag.run(id, t)); })(); + res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(id))); +}); + +// ── Aktualisieren ──────────────────────────────────────────────────────────── +router.put('/:id', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + const { title, code, language, description, tags } = req.body; + + // History-Eintrag wenn Code geändert + if (code !== undefined && code !== s.code) { + db.prepare(`INSERT INTO snippet_history (snippet_id, code, language, saved_at) VALUES (?,?,?,datetime('now','localtime'))`) + .run(s.id, s.code, s.language); + } + + db.prepare(`UPDATE snippets SET title=?,code=?,language=?,description=?,updated_at=datetime('now','localtime') WHERE id=?`) + .run(title??s.title, code??s.code, language??s.language, description??s.description, s.id); + + if (Array.isArray(tags)) { + db.prepare('DELETE FROM snippet_tags WHERE snippet_id=?').run(s.id); + const ins = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)'); + db.transaction(()=>{ tags.forEach(t => ins.run(s.id, t)); })(); + } + + res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(s.id))); +}); + +// ── Löschen ────────────────────────────────────────────────────────────────── +router.delete('/:id/history/:hid', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!s) return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('DELETE FROM snippet_history WHERE id=? AND snippet_id=?').run(req.params.hid, s.id); + res.json({ ok: true }); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM snippets WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json({ ok: true }); +}); + +// ── Shares ─────────────────────────────────────────────────────────────────── +router.get('/:id/shares', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + res.json(db.prepare(` + SELECT u.id, u.username, sh.created_at as shared_at + FROM snippet_shares sh JOIN users u ON u.id=sh.shared_with WHERE sh.snippet_id=? + `).all(s.id)); +}); + +router.post('/:id/share', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (target.id === uid(req)) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' }); + db.prepare(`INSERT OR IGNORE INTO snippet_shares (snippet_id,shared_by,shared_with,created_at) VALUES (?,?,?,datetime('now','localtime'))`) + .run(s.id, uid(req), target.id); + res.json({ ok: true }); +}); + +router.delete('/:id/share/:userId', authenticate, (req, res) => { + const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM snippet_shares WHERE snippet_id=? AND shared_with=?').run(s.id, req.params.userId); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/tools/statistik/routes.js b/backend/src/tools/statistik/routes.js new file mode 100644 index 0000000..d5203f7 --- /dev/null +++ b/backend/src/tools/statistik/routes.js @@ -0,0 +1,266 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); + +const uid = req => req.user.id; + +// ── Ausgaben CRUD ────────────────────────────────────────────────────────── +router.get('/expenses', authenticate, (req, res) => { + const { from, to } = req.query; + let q = 'SELECT * FROM expenses WHERE user_id=?'; + const p = [uid(req)]; + if (from) { q += ' AND date>=?'; p.push(from); } + if (to) { q += ' AND date<=?'; p.push(to); } + res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p)); +}); + +router.post('/expenses', authenticate, (req, res) => { + const { date, category, description, amount } = req.body; + if (!date || !description || !amount) return res.status(400).json({ error: 'Fehlende Felder' }); + const r = db.prepare('INSERT INTO expenses (user_id,date,category,description,amount) VALUES (?,?,?,?,?)') + .run(uid(req), date, category||'Sonstiges', description, parseFloat(amount)); + res.json(db.prepare('SELECT * FROM expenses WHERE id=?').get(r.lastInsertRowid)); +}); + +router.delete('/expenses/:id', authenticate, (req, res) => { + const e = db.prepare('SELECT id FROM expenses WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!e) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM expenses WHERE id=?').run(e.id); + res.json({ ok: true }); +}); + +// ── Revenue eines Auftrags ──────────────────────────────────────────────── +function getOrderRevenue(order) { + if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price); + const r = db.prepare( + 'SELECT COALESCE(SUM(custom_price * stueckzahl), 0) as s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL' + ).get(order.id); + return r?.s || 0; +} + +function getOrderBaseCost(orderId) { + const r = db.prepare(` + SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) as s + FROM order_items oi + LEFT JOIN calculations c ON c.id = oi.calculation_id + WHERE oi.order_id = ? + `).get(orderId); + return r?.s || 0; +} + +function round2(n) { return Math.round((n||0) * 100) / 100; } + +// ── Statistik-Übersicht ──────────────────────────────────────────────────── +router.get('/overview', authenticate, (req, res) => { + const { from, to } = req.query; + const u = uid(req); + + // Datums-Vergleich: ISO-String slice(0,10) = YYYY-MM-DD + const inRange = (dateStr) => { + if (!dateStr) return !from && !to; // kein Datum + kein Filter = ja; kein Datum + Filter = nein + const d = dateStr.slice(0, 10); + if (from && d < from) return false; + if (to && d > to) return false; + return true; + }; + + // Alle Aufträge + const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u); + + // Zeitraum-Filterung: + // - Bezahlte Aufträge: Datum = bezahlt_am (wann wurde Geld erhalten) + // - Offene Aufträge (noch nicht bezahlt): Datum = created_at (wann erstellt) + const filteredOrders = allOrders.filter(o => { + if (!from && !to) return true; // kein Filter → alle + if (o.bezahlt) { + // Bezahlt → nach bezahlt_am wenn vorhanden, sonst created_at + // Wenn bezahlt_am NULL ist (alte Aufträge vor dem Feature): created_at nutzen + const d = o.bezahlt_am || o.created_at; + return inRange(d); + } else { + return inRange(o.created_at); + } + }); + + const paidOrders = filteredOrders.filter(o => !!o.bezahlt); + const openOrders = filteredOrders.filter(o => !o.bezahlt && o.status !== 'warteliste'); + + // KPI-Werte für den gewählten Zeitraum + const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0); + const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0); + const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0); + const totalProfit = totalRevenue - totalBaseCost; + + // Ausgaben im Zeitraum + let eq = 'SELECT * FROM expenses WHERE user_id=?'; + const ep = [u]; + if (from) { eq += ' AND date>=?'; ep.push(from); } + if (to) { eq += ' AND date<=?'; ep.push(to); } + const expenses = db.prepare(eq + ' ORDER BY date DESC').all(...ep); + const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0); + const netProfit = totalProfit - totalExpenses; + + // Status-Verteilung im Zeitraum + const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 }; + for (const o of filteredOrders) { + if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++; + else if (!!o.bezahlt) byStatus.bezahlt++; + else if (o.status in byStatus) byStatus[o.status]++; + } + + // Ausgaben nach Kategorie (Zeitraum) + const byCategory = {}; + for (const e of expenses) { + byCategory[e.category] = (byCategory[e.category] || 0) + e.amount; + } + + // ── Diagramm-Daten: immer nach dem relevanten Datum gruppiert ──────────── + // Für Diagramme: alle Aufträge (ungefiltert) in Monatsbuckets einordnen + // damit das Balkendiagramm den vollen Kontext zeigt (letzte 12 Monate) + // ABER: wenn ein konkreter Zeitraum gewählt ist, zeigen wir nur diesen Zeitraum + // aufgeteilt in Tages/Wochen/Monats-Buckets je nach Länge + + // Granularität bestimmen + let granularity = 'month'; // Standard + if (from && to) { + const days = (new Date(to) - new Date(from)) / 86400000; + if (days <= 14) granularity = 'day'; + else if (days <= 31) granularity = 'week'; + // > 31 Tage → month (Monatsauswahl, Jahresauswahl, Gesamt) + } + + function getBucketKey(dateStr) { + if (!dateStr) return null; + const d = dateStr.slice(0, 10); // YYYY-MM-DD, immer lokal + if (granularity === 'day') return d; + if (granularity === 'week') { + // Montag der Woche – rein mit String-Arithmetik (kein UTC-Bug) + const [y, m, dd] = d.split('-').map(Number); + const dt = new Date(y, m - 1, dd); // lokaler Konstruktor + const day = dt.getDay() || 7; // 1=Mo … 7=So + dt.setDate(dt.getDate() - day + 1); + const py = dt.getFullYear(); + const pm = String(dt.getMonth() + 1).padStart(2, '0'); + const pd = String(dt.getDate()).padStart(2, '0'); + return `${py}-${pm}-${pd}`; + } + return d.slice(0, 7); // YYYY-MM + } + + // Buckets aus gefilterten Aufträgen (damit der Zeitraum stimmt) + const buckets = {}; + + // Auch alle Aufträge für Diagramm wenn kein Filter (zeige letzte 12 Monate) + const diagramOrders = (from || to) ? filteredOrders : allOrders; + + for (const o of diagramOrders) { + const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at; + const key = getBucketKey(dateStr); + if (!key) continue; + if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 }; + if (o.bezahlt) { + buckets[key].revenue += getOrderRevenue(o); + buckets[key].base_cost = (buckets[key].base_cost||0) + getOrderBaseCost(o.id); + } + buckets[key].orders++; + } + + // Ausgaben in Buckets (gefiltert) + const diagramExpenses = (from || to) ? expenses : db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u); + for (const e of diagramExpenses) { + const key = getBucketKey(e.date); + if (!key) continue; + if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 }; + buckets[key].expenses += e.amount; + } + + // Leere Buckets auffüllen – lokaler Date-Konstruktor um UTC-Bug zu vermeiden + if (from || to) { + const parseLocal = s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d); }; + const fmtLocal = dt => { + const y = dt.getFullYear(), m = String(dt.getMonth()+1).padStart(2,'0'), d = String(dt.getDate()).padStart(2,'0'); + return `${y}-${m}-${d}`; + }; + const firstBucket = Object.keys(buckets).sort()[0]; + const startD = parseLocal(from || firstBucket || to); + const endD = parseLocal(to || fmtLocal(new Date())); + const cur = new Date(startD); + while (cur <= endD) { + const key = getBucketKey(fmtLocal(cur)); + if (key && !buckets[key]) buckets[key] = { month:key, revenue:0, base_cost:0, expenses:0, orders:0 }; + if (granularity === 'day') cur.setDate(cur.getDate() + 1); + else if (granularity === 'week') cur.setDate(cur.getDate() + 7); + else cur.setMonth(cur.getMonth() + 1); + } + } + + // Sortiert + ohne Filter auf letzte 12 Monate begrenzen + let monthlyData = Object.values(buckets) + .sort((a,b) => a.month.localeCompare(b.month)) + .map(m => ({ ...m, revenue: round2(m.revenue), base_cost: round2(m.base_cost||0), expenses: round2(m.expenses) })); + + if (!from && !to) monthlyData = monthlyData.slice(-12); + + // Kumulierter Nettogewinn (pro Bucket) + let cum = 0; + const cumulativeData = monthlyData.map(m => { + cum += (m.revenue||0) - (m.base_cost||0) - (m.expenses||0); + return { month: m.month, value: round2(cum) }; + }); + + // Aufträge-Tabelle mit korrekten Werten + const recentOrders = filteredOrders.slice(0, 50).map(o => ({ + id: o.id, name: o.name, status: o.status, + bezahlt: o.bezahlt, abgeholt: o.abgeholt, + created_at: o.created_at, bezahlt_am: o.bezahlt_am, + revenue: round2(getOrderRevenue(o)), + base_cost: round2(getOrderBaseCost(o.id)), + })); + + const paidOrdersOut = paidOrders.slice(0,50).map(o => ({ + id: o.id, name: o.name, status: o.status, + bezahlt: o.bezahlt, abgeholt: o.abgeholt, + created_at: o.created_at, bezahlt_am: o.bezahlt_am, + revenue: round2(getOrderRevenue(o)), + base_cost: round2(getOrderBaseCost(o.id)), + })); + + res.json({ + totalRevenue: round2(totalRevenue), + openRevenue: round2(openRevenue), + totalBaseCost: round2(totalBaseCost), + totalProfit: round2(totalProfit), + totalExpenses: round2(totalExpenses), + netProfit: round2(netProfit), + totalOrders: filteredOrders.length, + granularity, + byStatus, byCategory, monthlyData, cumulativeData, + expenses: expenses.slice(0, 100), + recentOrders, + paidOrders: paidOrdersOut, + }); +}); + +// Bestellungen nach created_at (für "Bestellungen diesen Monat" Tab) +router.get('/month-orders', authenticate, (req, res) => { + const { from, to } = req.query; + const u = uid(req); + let q = 'SELECT * FROM orders WHERE user_id=?'; + const p = [u]; + if (from) { q += ' AND DATE(created_at)>=?'; p.push(from); } + if (to) { q += ' AND DATE(created_at)<=?'; p.push(to); } + q += ' ORDER BY created_at DESC'; + const orders = db.prepare(q).all(...p); + res.json(orders.map(o => ({ + id: o.id, name: o.name, status: o.status, + bezahlt: o.bezahlt, abgeholt: o.abgeholt, + created_at: o.created_at, bezahlt_am: o.bezahlt_am, + revenue: round2(getOrderRevenue(o)), + base_cost: round2(getOrderBaseCost(o.id)), + }))); +}); + + + +module.exports = router; diff --git a/backend/src/tools/whiteboard/routes.js b/backend/src/tools/whiteboard/routes.js new file mode 100644 index 0000000..b4fff99 --- /dev/null +++ b/backend/src/tools/whiteboard/routes.js @@ -0,0 +1,257 @@ +const express = require('express'); +const db = require('../../db'); +const { logPush } = require('../../pushLog'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS whiteboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT 'Neues Whiteboard', + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + CREATE TABLE IF NOT EXISTS whiteboard_data ( + whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE, + elements TEXT NOT NULL DEFAULT '[]', + viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}' + ); + CREATE TABLE IF NOT EXISTS whiteboard_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'edit', + UNIQUE(whiteboard_id, user_id) + ); + CREATE TABLE IF NOT EXISTS whiteboard_unread ( + whiteboard_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT 'updated', + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + PRIMARY KEY (whiteboard_id, user_id) + ); + `); +})(); + +const uid = req => req.user.id; + +function getAccessUsers(whiteboardId, exceptUserId) { + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); + if (!wb) return []; + const users = new Set(); + if (wb.owner_id !== exceptUserId) users.add(wb.owner_id); + const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(whiteboardId); + for (const p of perms) if (p.user_id !== exceptUserId) users.add(p.user_id); + return [...users]; +} + +function markUnread(whiteboardId, userIds, reason = 'updated') { + const upsert = db.prepare(` + INSERT INTO whiteboard_unread (whiteboard_id, user_id, reason, created_at) + VALUES (?, ?, ?, datetime('now','localtime')) + ON CONFLICT(whiteboard_id, user_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at + `); + for (const u of userIds) upsert.run(whiteboardId, u, reason); +} + +function markRead(whiteboardId, userId) { + db.prepare('DELETE FROM whiteboard_unread WHERE whiteboard_id=? AND user_id=?').run(whiteboardId, userId); +} + +// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück +function access(whiteboardId, userId) { + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); + if (!wb) return null; + if (wb.owner_id === userId) return 'owner'; + const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId); + return perm?.role || null; +} + +// ── Unread count ────────────────────────────────────────────────────────────── +router.get('/unread', authenticate, (req, res) => { + const me = uid(req); + const { count } = db.prepare('SELECT COUNT(*) as count FROM whiteboard_unread WHERE user_id=?').get(me); + res.json({ count }); +}); + +// ── Unread IDs (welche Whiteboards konkret) ─────────────────────────────────── +router.get('/unread-ids', authenticate, (req, res) => { + const me = uid(req); + const rows = db.prepare('SELECT whiteboard_id FROM whiteboard_unread WHERE user_id=?').all(me); + res.json({ ids: rows.map(r => r.whiteboard_id) }); +}); + +// ── Liste ───────────────────────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + // Eigene + geteilte Whiteboards + const own = db.prepare(` + SELECT w.*, 'owner' as role, u.username as owner_name + FROM whiteboards w JOIN users u ON u.id=w.owner_id + WHERE w.owner_id=? ORDER BY w.updated_at DESC + `).all(me); + const shared = db.prepare(` + SELECT w.*, p.role, u.username as owner_name + FROM whiteboards w + JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=? + JOIN users u ON u.id=w.owner_id + ORDER BY w.updated_at DESC + `).all(me); + res.json({ whiteboards: [...own, ...shared] }); +}); + +// ── Erstellen ───────────────────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const { title = 'Neues Whiteboard' } = req.body; + const me = uid(req); + const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard'); + db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid); + res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid)); +}); + +// ── Umbenennen ──────────────────────────────────────────────────────────────── +router.patch('/:id/title', authenticate, (req, res) => { + const { title } = req.body; + if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' }); + const me = uid(req); + const role = access(req.params.id, me); + if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id); + res.json({ ok: true }); +}); + +// ── Löschen ─────────────────────────────────────────────────────────────────── +router.delete('/:id', authenticate, (req, res) => { + const me = uid(req); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + if (!wb) return res.status(404).json({ error: 'Nicht gefunden' }); + if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' }); + db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id); + res.json({ ok: true }); +}); + +// ── Canvas laden ────────────────────────────────────────────────────────────── +router.get('/:id/data', authenticate, (req, res) => { + const me = uid(req); + const role = access(req.params.id, me); + if (!role) return res.status(403).json({ error: 'Kein Zugriff' }); + const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + const perms = db.prepare(` + SELECT p.*, u.username FROM whiteboard_permissions p + JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=? + `).all(req.params.id); + const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id); + markRead(req.params.id, me); + res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms }); +}); + +// ── Canvas speichern ────────────────────────────────────────────────────────── +// Feste Route vor :id-Routen +router.post('/:id/save', authenticate, async (req, res) => { + const me = uid(req); + const role = access(req.params.id, me); + if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' }); + const { elements, viewport } = req.body; + db.prepare(` + UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=? + `).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id); + db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id); + res.json({ ok: true }); + + // Pushover an alle anderen mit Zugriff (fire & forget) + try { + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + const saver = db.prepare('SELECT username FROM users WHERE id=?').get(me); + const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`; + + // Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst + const notify = getAccessUsers(req.params.id, me); + markUnread(req.params.id, notify, 'updated'); + + for (const userId of notify) { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); + if (!poCfg?.app_token || !poCfg?.user_key) continue; + const title = '🖊 Whiteboard aktualisiert'; + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title, + message, + priority: 0, + }), + }).catch(() => {}); + logPush({ userId, title, message, priority: 0, source: 'whiteboard-save' }); + } + } catch {} +}); + +// ── Berechtigungen: alle User für Share-Modal ───────────────────────────────── +router.get('/users-list', authenticate, (req, res) => { + const me = uid(req); + const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me); + res.json({ users }); +}); + +// ── Berechtigungen setzen ───────────────────────────────────────────────────── +router.put('/:id/permissions', authenticate, async (req, res) => { + const me = uid(req); + const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id); + if (!wb) return res.status(404).json({ error: 'Nicht gefunden' }); + if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' }); + const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}] + if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' }); + + // Vorherigen Stand merken um neue User zu erkennen + const before = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(req.params.id).map(r => r.user_id); + + const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role"); + const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?"); + db.transaction(() => { + for (const p of permissions) { + if (!p.user_id) continue; + if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id); + else upsert.run(req.params.id, p.user_id, p.role); + } + })(); + + const owner = db.prepare('SELECT username FROM users WHERE id=?').get(me); + const newlyAdded = permissions.filter(p => p.role && p.role !== 'none' && !before.includes(p.user_id)); + if (newlyAdded.length) markUnread(req.params.id, newlyAdded.map(p => p.user_id), 'shared'); + + for (const p of newlyAdded) { + try { + const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id); + if (!poCfg?.app_token || !poCfg?.user_key) continue; + const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen'; + const title = '🖊 Whiteboard geteilt'; + const message = `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`; + fetch('https://api.pushover.net/1/messages.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: poCfg.app_token, + user: poCfg.user_key, + title, + message, + priority: 0, + }), + }).catch(() => {}); + logPush({ userId: p.user_id, title, message, priority: 0, source: 'whiteboard-share' }); + } catch {} + } + + const updated = db.prepare(` + SELECT p.*, u.username FROM whiteboard_permissions p + JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=? + `).all(req.params.id); + res.json({ permissions: updated }); +}); + +module.exports = router; diff --git a/backend/upload-page.html b/backend/upload-page.html new file mode 100644 index 0000000..bf129d6 --- /dev/null +++ b/backend/upload-page.html @@ -0,0 +1,238 @@ + + + + + +Datei hochladen – DickenDock + + + +
+

📤 Datei hochladen

+

Dateien senden

+ +
+
⏳ Link wird geprüft…
+ + + + + +
+ + +
+ + + + diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..4c198f7 --- /dev/null +++ b/build.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Dicken Dock – Image bauen und in Gitea-Registry pushen +# Ausführen auf dem Server: sh build.sh + +REGISTRY="192.168.1.26:3000" +USER="admin" +IMAGE="$REGISTRY/$USER/dickendock:latest" +REPO="http://$REGISTRY/$USER/dickendock.git" + +echo "🔨 Baue Image direkt aus Gitea..." +docker build "$REPO" -t "$IMAGE" + +echo "📤 Pushe in Registry..." +docker push "$IMAGE" + +echo "✅ Fertig – starte mit: docker compose up -d" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4ae04c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + dickendock: + build: + context: "http://192.168.1.26:3000/Dicken/dickendock.git#main" + dockerfile: Dockerfile + container_name: dickendock + restart: unless-stopped + env_file: + - .env + environment: + - GITEA_URL=http://192.168.1.26:3000 + - GITEA_REPO=Dicken/dickendock + - TZ=Europe/Berlin + volumes: + - ./data:/data + ports: + - "8080:4000" \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..598cf7d --- /dev/null +++ b/env.example @@ -0,0 +1,12 @@ +# Kopiere diese Datei zu .env und passe die Werte an +# .env wird NICHT ins Git eingecheckt + +JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string + +# Home Assistant / Mosquitto MQTT (optional — ohne MQTT_HOST bleibt die +# Integration deaktiviert, der Rest der App läuft unverändert) +MQTT_HOST=192.168.1.222 +MQTT_PORT=1883 +MQTT_USERNAME=dickendock +MQTT_PASSWORD=ersetze-mich +MQTT_BASE_TOPIC=dickendock diff --git a/frontend/generate-icons.mjs b/frontend/generate-icons.mjs new file mode 100644 index 0000000..67fb64f --- /dev/null +++ b/frontend/generate-icons.mjs @@ -0,0 +1,106 @@ +// Generates icon-192.png and icon-512.png without external dependencies +// Uses a minimal PNG encoder written in pure JS +import { writeFileSync } from 'fs'; +import { deflateSync } from 'zlib'; + +function createPNG(size) { + const r = Math.round(size * 0.22); + + // Draw pixels + const pixels = new Uint8Array(size * size * 4); + + const setPixel = (x, y, R, G, B, A = 255) => { + if (x < 0 || x >= size || y < 0 || y >= size) return; + const i = (y * size + x) * 4; + pixels[i] = R; pixels[i+1] = G; pixels[i+2] = B; pixels[i+3] = A; + }; + + // Gradient background with rounded corners + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + // Rounded corner check + const inCornerTL = x < r && y < r && (x-r)**2 + (y-r)**2 > r**2; + const inCornerTR = x >= size-r && y < r && (x-(size-r))**2 + (y-r)**2 > r**2; + const inCornerBL = x < r && y >= size-r && (x-r)**2 + (y-(size-r))**2 > r**2; + const inCornerBR = x >= size-r && y >= size-r && (x-(size-r))**2 + (y-(size-r))**2 > r**2; + if (inCornerTL || inCornerTR || inCornerBL || inCornerBR) continue; + + // Linear gradient: top-left #4ecdc4 → bottom-right #ff6b9d + const t = (x + y) / (size * 2); + const R = Math.round(0x4e + (0xff - 0x4e) * t); + const G = Math.round(0xcd + (0x6b - 0xcd) * t); + const B = Math.round(0xc4 + (0x9d - 0xc4) * t); + setPixel(x, y, R, G, B); + } + } + + // Draw ⚒ as a simple hammer shape (dark color on gradient) + const cx = Math.floor(size / 2); + const cy = Math.floor(size / 2); + const s = Math.floor(size * 0.18); + + // Hammer head (rectangle) + for (let dy = -s; dy <= 0; dy++) { + for (let dx = -s; dx <= s; dx++) { + setPixel(cx + dx, cy + dy, 13, 13, 15); + } + } + // Handle + for (let dy = 0; dy <= s * 2; dy++) { + for (let dx = -Math.floor(s*0.25); dx <= Math.floor(s*0.25); dx++) { + setPixel(cx + dx, cy + dy, 13, 13, 15); + } + } + + // Encode as PNG + const width = size, height = size; + const signature = Buffer.from([137,80,78,71,13,10,26,10]); + + // IHDR + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB... wait we need RGBA + // Use RGBA (color type 6) + ihdr[9] = 6; + const ihdrChunk = makeChunk('IHDR', ihdr); + + // IDAT - raw image data with filter bytes + const raw = Buffer.alloc(height * (1 + width * 4)); + for (let y = 0; y < height; y++) { + raw[y * (1 + width * 4)] = 0; // filter type None + for (let x = 0; x < width; x++) { + const si = (y * width + x) * 4; + const di = y * (1 + width * 4) + 1 + x * 4; + raw[di] = pixels[si]; + raw[di+1] = pixels[si+1]; + raw[di+2] = pixels[si+2]; + raw[di+3] = pixels[si+3] || (pixels[si] || pixels[si+1] || pixels[si+2] ? 255 : 0); + } + } + const compressed = deflateSync(raw); + const idatChunk = makeChunk('IDAT', compressed); + const iendChunk = makeChunk('IEND', Buffer.alloc(0)); + + return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]); +} + +function makeChunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const typeB = Buffer.from(type); + const crc = crc32(Buffer.concat([typeB, data])); + const crcB = Buffer.alloc(4); crcB.writeUInt32BE(crc >>> 0); + return Buffer.concat([len, typeB, data, crcB]); +} + +function crc32(buf) { + let crc = 0xFFFFFFFF; + for (const b of buf) { + crc ^= b; + for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0); + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +writeFileSync('public/icon-192.png', createPNG(192)); +writeFileSync('public/icon-512.png', createPNG(512)); +console.log('Icons generated.'); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fa8ccd5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + Dicken Dock + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1935a6d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "dickendock-frontend", + "version": "1.0.0", + "scripts": { + "prebuild": "node generate-icons.mjs", + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "qrcode": "^1.5.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..f576035 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..fed0dec Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..024646e Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..8c4cade --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,7 @@ + + + + DD + DOCK + + diff --git a/frontend/public/koepi/aldi.png b/frontend/public/koepi/aldi.png new file mode 100644 index 0000000..0d7ee38 Binary files /dev/null and b/frontend/public/koepi/aldi.png differ diff --git a/frontend/public/koepi/edeka.png b/frontend/public/koepi/edeka.png new file mode 100644 index 0000000..18a05dd Binary files /dev/null and b/frontend/public/koepi/edeka.png differ diff --git a/frontend/public/koepi/hornbach.png b/frontend/public/koepi/hornbach.png new file mode 100644 index 0000000..f1c24b9 Binary files /dev/null and b/frontend/public/koepi/hornbach.png differ diff --git a/frontend/public/koepi/kasten_033.png b/frontend/public/koepi/kasten_033.png new file mode 100644 index 0000000..21279ef Binary files /dev/null and b/frontend/public/koepi/kasten_033.png differ diff --git a/frontend/public/koepi/kasten_033_05.png b/frontend/public/koepi/kasten_033_05.png new file mode 100644 index 0000000..57ef7c0 Binary files /dev/null and b/frontend/public/koepi/kasten_033_05.png differ diff --git a/frontend/public/koepi/kasten_033_055.png b/frontend/public/koepi/kasten_033_055.png new file mode 100644 index 0000000..57ef7c0 Binary files /dev/null and b/frontend/public/koepi/kasten_033_055.png differ diff --git a/frontend/public/koepi/kasten_050.png b/frontend/public/koepi/kasten_050.png new file mode 100644 index 0000000..332b5e5 Binary files /dev/null and b/frontend/public/koepi/kasten_050.png differ diff --git a/frontend/public/koepi/kasten_11er.png b/frontend/public/koepi/kasten_11er.png new file mode 100644 index 0000000..cbd3235 Binary files /dev/null and b/frontend/public/koepi/kasten_11er.png differ diff --git a/frontend/public/koepi/kasten_lang.png b/frontend/public/koepi/kasten_lang.png new file mode 100644 index 0000000..cbd3235 Binary files /dev/null and b/frontend/public/koepi/kasten_lang.png differ diff --git a/frontend/public/koepi/kasten_steini.png b/frontend/public/koepi/kasten_steini.png new file mode 100644 index 0000000..d0aff89 Binary files /dev/null and b/frontend/public/koepi/kasten_steini.png differ diff --git a/frontend/public/koepi/kaufland.png b/frontend/public/koepi/kaufland.png new file mode 100644 index 0000000..1fb2efa Binary files /dev/null and b/frontend/public/koepi/kaufland.png differ diff --git a/frontend/public/koepi/koepi_platzhalter.png b/frontend/public/koepi/koepi_platzhalter.png new file mode 100644 index 0000000..3c54c30 Binary files /dev/null and b/frontend/public/koepi/koepi_platzhalter.png differ diff --git a/frontend/public/koepi/lidl.png b/frontend/public/koepi/lidl.png new file mode 100644 index 0000000..b2d5403 Binary files /dev/null and b/frontend/public/koepi/lidl.png differ diff --git a/frontend/public/koepi/netto.png b/frontend/public/koepi/netto.png new file mode 100644 index 0000000..9a2f2cc Binary files /dev/null and b/frontend/public/koepi/netto.png differ diff --git a/frontend/public/koepi/netto_getraenkemarkt.png b/frontend/public/koepi/netto_getraenkemarkt.png new file mode 100644 index 0000000..c3e4243 Binary files /dev/null and b/frontend/public/koepi/netto_getraenkemarkt.png differ diff --git a/frontend/public/koepi/palette_050.png b/frontend/public/koepi/palette_050.png new file mode 100644 index 0000000..d55d609 Binary files /dev/null and b/frontend/public/koepi/palette_050.png differ diff --git a/frontend/public/koepi/penny.png b/frontend/public/koepi/penny.png new file mode 100644 index 0000000..18d1d6b Binary files /dev/null and b/frontend/public/koepi/penny.png differ diff --git a/frontend/public/koepi/rewe.png b/frontend/public/koepi/rewe.png new file mode 100644 index 0000000..b859df4 Binary files /dev/null and b/frontend/public/koepi/rewe.png differ diff --git a/frontend/public/koepi/traeger.png b/frontend/public/koepi/traeger.png new file mode 100644 index 0000000..b590c94 Binary files /dev/null and b/frontend/public/koepi/traeger.png differ diff --git a/frontend/public/koepi/trinkgut.png b/frontend/public/koepi/trinkgut.png new file mode 100644 index 0000000..4faf727 Binary files /dev/null and b/frontend/public/koepi/trinkgut.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..8b8b433 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Dicken Dock", + "short_name": "DickenDock", + "description": "Dein 3D-Druck Verwaltungstool", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0d0d0f", + "theme_color": "#4ecdc4", + "icons": [ + { + "src": "/favicon.svg", + "type": "image/svg+xml", + "sizes": "any", + "purpose": "any maskable" + }, + { + "src": "/icon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..d7580d8 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,55 @@ +// Service Worker – Version wird zur Build-Zeit injiziert via Dockerfile +// Dadurch erkennt der Browser bei jedem Deploy einen neuen SW → Cache-Purge +const VERSION = '__BUILD_VERSION__'; // wird im Dockerfile ersetzt +const CACHE_NAME = 'dickendock-' + VERSION; + +self.addEventListener('install', () => { + self.skipWaiting(); // neuen SW sofort übernehmen +}); + +self.addEventListener('activate', e => { + e.waitUntil((async () => { + // alle alten Caches löschen + const keys = await caches.keys(); + await Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))); + await self.clients.claim(); + // allen offenen Clients sagen: neue Version aktiv → reload + const clients = await self.clients.matchAll({ type: 'window' }); + clients.forEach(c => c.postMessage({ type: 'SW_ACTIVATED', version: VERSION })); + })()); +}); + +self.addEventListener('fetch', e => { + const url = new URL(e.request.url); + + // Cross-Origin-Requests nicht abfangen + if (url.origin !== self.location.origin) return; + + // API immer direkt ans Netz + if (url.pathname.startsWith('/api/')) { + e.respondWith(fetch(e.request)); + return; + } + + // sw.js selbst nie cachen + if (url.pathname === '/sw.js') { + e.respondWith(fetch(e.request, { cache: 'no-store' })); + return; + } + + // HTML-Navigation: IMMER frisch vom Netzwerk + if (e.request.mode === 'navigate') { + e.respondWith( + fetch(e.request, { cache: 'reload' }) + .catch(() => caches.match('/index.html')) + ); + return; + } + + // Same-Origin Assets: durchreichen + e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); +}); + +self.addEventListener('message', e => { + if (e.data === 'SKIP_WAITING') self.skipWaiting(); +}); diff --git a/frontend/public/vite.config.js b/frontend/public/vite.config.js new file mode 100644 index 0000000..af132f8 --- /dev/null +++ b/frontend/public/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { proxy: { '/api': 'http://localhost:4000' } }, + define: { + __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + }, +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..42e1717 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,4396 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api, S } from './lib.js'; +import { TOOLS, getGroupedTools } from './toolRegistry.js'; +import CalendarWidget, { CalendarSettings } from './calendar.jsx'; +import { DateiSettings } from './tools/dateien.jsx'; +import { useConfirm } from './confirm.jsx'; +import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, TrashIcon, MessageIcon } from './icons.jsx'; +import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, exportEncrypted, importEncrypted, generateNewKeyPair } from './crypto.js'; + +const CONST_ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡']; + +// Schlüsselpaar beim Login generieren und public key registrieren (fire & forget) +async function initNachrichtenKeys() { + const LS_KEY = 'dd_msg_keypair'; + let pubJwk; + const stored = localStorage.getItem(LS_KEY); + if (stored) { + try { pubJwk = JSON.parse(stored).pub; } catch { localStorage.removeItem(LS_KEY); } + } + if (!pubJwk) { + const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); + pubJwk = await crypto.subtle.exportKey('jwk', kp.publicKey); + const priv = await crypto.subtle.exportKey('jwk', kp.privateKey); + localStorage.setItem(LS_KEY, JSON.stringify({ pub: pubJwk, priv })); + } + try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(pubJwk) } }); } catch {} +} + +// ── Responsive Hook ─────────────────────────────────────────────────────────── +function useIsMobile() { + const [mobile, setMobile] = useState(window.innerWidth < 768); + useEffect(() => { + // Nur auf Breite reagieren, nicht auf Keyboard-Open (Höhenänderung) + const fn = () => { + const isMob = window.innerWidth < 768; + setMobile(prev => prev === isMob ? prev : isMob); + }; + window.addEventListener('resize', fn); + return () => window.removeEventListener('resize', fn); + }, []); + return mobile; +} + +// ── Toast ───────────────────────────────────────────────────────────────────── +function Toast({ msg, type }) { + if (!msg) return null; + const c = type === 'error' ? '#ff6b9d' : '#4ecdc4'; + return ( +
+ {type==='error'?'✕ ':'✓ '}{msg} +
+ ); +} + +// ── Modal ───────────────────────────────────────────────────────────────────── +function Modal({ title, onClose, children }) { + return ( +
=768 ? 'center' : 'flex-end', + justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }} + onClick={e=>e.target===e.currentTarget&&onClose()}> +
=768 ? 16 : '16px 16px 0 0', + padding:'20px 20px 40px', width:'100%', + maxWidth:600, maxHeight:'85vh', overflowY:'auto', + boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}> + {window.innerWidth < 768 &&
} +
+ {title} + +
+ {children} +
+
+ ); +} + +// ── PWA Splash Screen ───────────────────────────────────────────────────────── +function SplashScreen({ onDone }) { + const [fade, setFade] = useState(false); + useEffect(() => { + const t1 = setTimeout(() => setFade(true), 1200); + const t2 = setTimeout(() => onDone(), 1700); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, []); + return ( +
+
+ DICKENDOCK +
+
+
+ ); +} + +// ── Login ───────────────────────────────────────────────────────────────────── +function Login({ onLogin }) { + const [u,setU]=useState(''); const [p,setP]=useState(''); + const [err,setErr]=useState(''); const [busy,setBusy]=useState(false); + const go = async () => { + setErr(''); setBusy(true); + try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); initNachrichtenKeys(); onLogin(d.user); } + catch(e) { setErr(e.message); } finally { setBusy(false); } + }; + return ( +
+
+
+
+
+ DICKENDOCK +
+
+ {[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>( +
+ + s(e.target.value)} + onKeyDown={e=>e.key==='Enter'&&go()} + style={{ ...S.inp, fontSize:16, padding:'13px 14px', borderRadius:10, + border:'1px solid rgba(255,255,255,0.12)' }} + autoCapitalize="none" autoCorrect="off" /> +
+ ))} + {err &&
✕ {err}
} + +
+
+ ); +} + +// ── Update Modal ────────────────────────────────────────────────────────────── +// ── Desktop Sidebar ─────────────────────────────────────────────────────────── +function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) { + const [appsOpen, setAppsOpen] = useState(true); + const [collapsed, setCollapsed] = useState(false); + const [collapsedGroups, setCollapsedGroups] = useState({}); + const toggleGroup = (group) => setCollapsedGroups(prev => ({ ...prev, [group]: !prev[group] })); + + const NavBtn = ({ item, sub=false }) => { + const ic = active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.55)'; + return ( + + ); + }; + + return ( + + ); +} + +// ── Mobile Bottom Navigation ────────────────────────────────────────────────── +function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) { + const [menuOpen, setMenuOpen] = useState(false); + const [appsOpen, setAppsOpen] = useState(false); + const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); + const NAV_H = 56; + + const MI = ({Icon, icon, label, onClick, color='rgba(255,255,255,0.85)', dot=false}) => { + const Ic = Icon; + return ( + + ); + }; + + const Sheet = ({open, onClose, children}) => { + if (!open) return null; + const isDesktop = window.innerWidth >= 768; + if (isDesktop) return ( +
+
e.stopPropagation()}> + {children} +
+
+ ); + return ( +
+
e.stopPropagation()}> +
+ {children} +
+
+
+ ); + }; + + const mainNav = [ + { id:'dashboard', Icon:HomeIcon, label:'Home' }, + { id:'_apps', Icon:AppsIcon, label:'Apps' }, + { id:'admin', Icon:AdminIcon, label:'Einst.' }, + { id:'_mehr', Icon:MoreIcon, label:'Mehr' }, + ]; + + return ( + <> + {/* Apps Sheet mit Gruppenüberschriften */} + setAppsOpen(false)}> +
+ {getGroupedTools(user?.role==='admin', hiddenTools).map(([group, tools]) => { + const isGrpCollapsed = !!mobileCollapsedGroups[group]; + return ( +
+ + {!isGrpCollapsed && tools.map(t => ( + 0) || (t.id==='whiteboard' && whiteboardUnread>0) || (t.id==='nachrichten' && unreadMsgs>0)} + onClick={() => { setActive(t.id); setAppsOpen(false); }} /> + ))} +
+ ); + })} +
+
+ + {/* Mehr Sheet */} + setMenuOpen(false)}> +
+ {}} color="rgba(255,255,255,0.35)"/> + {onLogout();setMenuOpen(false);}} color="#ff6b9d"/> + + + {/* Bottom Bar */} + + + ); +} + + +// ── Dashboard Widgets ───────────────────────────────────────────────────────── +// ── QuickLinks Carousel (Mobile) ───────────────────────────────────────────── +function QuickLinksCarousel({ links, onFolderClick, navigateTool }) { + const perRow = 4; + const gap = 6; + const pageSize = perRow * 2; + const pageCount = Math.ceil(links.length / pageSize); + + const [page, setPage] = useState(0); + const scrollRef = useRef(null); + + const onScroll = () => { + if (!scrollRef.current) return; + const p = Math.round(scrollRef.current.scrollLeft / scrollRef.current.offsetWidth); + setPage(p); + }; + + const canLeft = page > 0; + const canRight = page < pageCount - 1; + + const scrollTo = (p) => { + scrollRef.current?.scrollTo({ left: p * scrollRef.current.offsetWidth, behavior:'smooth' }); + }; + + if (pageCount <= 1) return ( +
+ {links.map(l => )} +
+ ); + + return ( +
+
+ {Array.from({length: pageCount}, (_,pi) => ( +
+ {links.slice(pi*pageSize, (pi+1)*pageSize).map(l => )} +
+ ))} +
+ + {/* Page dots */} + {pageCount > 1 && ( +
+ {Array.from({length:pageCount}, (_,i) => ( +
scrollTo(i)} style={{ + width: i===page ? 14 : 5, height:5, borderRadius:3, cursor:'pointer', + background: i===page ? '#4ecdc4' : 'rgba(255,255,255,0.2)', + transition:'all 0.2s', + }}/> + ))} +
+ )} +
+ ); +} + +function QuickLinkIcon({ l, onFolderClick, navigateTool }) { + const [imgOk, setImgOk] = useState(true); + const isTool = l.url?.startsWith('tool://'); + + const hasCustomIcon = l.icon && l.icon !== '🔗'; + const faviconUrl = (!hasCustomIcon && !l.isFolder && !isTool) ? (() => { + try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(l.url).hostname}`; } + catch { return null; } + })() : null; + + const showFavicon = !hasCustomIcon && !!faviconUrl && imgOk && !l.isFolder && !isTool; + const showCustomImg = hasCustomIcon && !l.isFolder && l.icon.startsWith('http'); + const showEmoji = !showFavicon && !showCustomImg; + const emoji = l.isFolder ? (l.icon||'📁') : (hasCustomIcon ? l.icon : (isTool ? '⚡' : '🔗')); + + const content = ( + <> + {showFavicon && setImgOk(false)}/>} + {showCustomImg && setImgOk(false)}/>} + {showEmoji && {emoji}} + {l.title||l.name} + + ); + + const baseStyle = {display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:3, + padding:'4px 2px',background: isTool ? 'rgba(160,132,250,0.06)' : 'rgba(255,255,255,0.04)', + border: l.isFolder ? '1px solid rgba(78,205,196,0.25)' : isTool ? '1px solid rgba(160,132,250,0.2)' : '1px solid rgba(255,255,255,0.08)', + borderRadius:10,textDecoration:'none',height:48,width:'100%',boxSizing:'border-box',cursor:'pointer'}; + + if (isTool) { + return ; + } + if (l.isFolder && onFolderClick) { + const realId = typeof l.id==='string'&&l.id.startsWith('folder-') ? parseInt(l.id.replace('folder-','')) : l.id; + return ; + } + return {content}; +} + +function FolderModal({ folder, onClose, navigateTool }) { + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(()=>{ + api(`/tools/linkliste/folders/${folder.id}/links`) + .then(d=>{ setLinks(d.links||[]); setLoading(false); }) + .catch(()=>setLoading(false)); + },[folder.id]); + const mob = window.innerWidth < 768; + return ( +
e.target===e.currentTarget&&onClose()}> +
+ {mob&&
} +
+
+ {folder.icon} {folder.name} +
+ +
+ {loading ?
+ : links.length===0 ?
Ordner ist leer
+ :
+ {links.map(l=>)} +
+ } +
+
+ ); +} + +function QuickLinks({ toast, mobile, setActive, setDevToolsNav }) { + const [links, setLinks] = useState([]); + const [folderModal, setFolderModal] = useState(null); + const [editMode, setEditMode] = useState(false); + + useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []); + + // Tool-Navigation für tool://-Links + const navigateTool = (url) => { + if (!url?.startsWith('tool://')) return; + const withoutScheme = url.replace('tool://', ''); + const [toolId, query] = withoutScheme.split('?'); + const sub = query?.replace('sub=', ''); + if (toolId === 'devtools' && sub && setDevToolsNav) { + setActive('devtools'); + setDevToolsNav({ tool: sub, ts: Date.now() }); + } else if (setActive) { + setActive(toolId); + } + }; + + const legacyItems = links.filter(l => l.item_type === 'quicklink' || !l.item_type); + const hasLegacy = legacyItems.length > 0; + + const deleteLegacy = async id => { + try { + await api(`/dashboard/links/${id}`, { method:'DELETE' }); + setLinks(p => p.filter(l => l.id !== id)); + toast('Entfernt'); + } catch(e) { toast(e.message,'error'); } + }; + + const allItems = links.map(l => l.item_type === 'folder' + ? { ...l, id:`folder-${l.id}`, isFolder:true, icon:l.icon||'📁', title:l.name } + : l + ); + + return ( +
+ {folderModal && setFolderModal(null)} navigateTool={navigateTool}/>} +
+
SCHNELLZUGRIFF
+ {hasLegacy && ( + + )} +
+ + {/* Edit-Modus: alte Quick-Links verwalten */} + {editMode && hasLegacy && ( +
+
+ ÄLTERE LINKS (aus Einstellungen) — können hier gelöscht werden +
+ {legacyItems.map(l=>( +
+ {l.icon?.startsWith('http') + ? + : l.icon||'🔗'} +
+
{l.title}
+
+ +
+ ))} +
+ Neue Links über Linkliste → ● Schnellzugriff verwalten +
+
+ )} + + {allItems.length === 0 + ?
+ Links oder Ordner in der Linkliste mit ● Schnellzugriff aktivieren. +
+ : mobile + ? + :
+ {allItems.map(l => )} +
+ } +
+ ); +} + +function TodoList({ toast }) { + const [items, setItems] = useState([]); + const [text, setText] = useState(''); + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { api('/dashboard/todos').then(setItems).catch(() => {}); }, []); + + const add = async () => { + if (!text.trim()) return; + try { const n = await api('/dashboard/todos', { body:{ text:text.trim() } }); setItems(p => [...p, n]); setText(''); } + catch(e) { toast(e.message, 'error'); } + }; + const toggle = async item => { + try { const u = await api(`/dashboard/todos/${item.id}`, { method:'PUT', body:{ done:!item.done } }); setItems(p => p.map(i => i.id===item.id ? u : i)); } + catch(e) { toast(e.message, 'error'); } + }; + const del = async id => { + try { await api(`/dashboard/todos/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id!==id)); } + catch(e) { toast(e.message, 'error'); } + }; + + const open = items.filter(i => !i.done); + const done = items.filter(i => i.done); + + return ( +
+ + {isOpen && <>
+ setText(e.target.value)} + onKeyDown={e => e.key==='Enter' && add()} + style={{ ...S.inp, flex:1, fontSize:16 }} placeholder="Neue Aufgabe…" /> + +
+
+ {open.length === 0 && done.length === 0 && ( +
Noch keine Aufgaben.
+ )} + {open.map(item => ( +
+ +
+ ))} + {done.length > 0 && ( + <> +
ERLEDIGT
+ {done.map(item => ( +
+ + {item.text} + +
+ ))} + + )} +
} +
+ ); +} + +function Notepad({ toast }) { + const [content, setContent] = useState(''); + const [saved, setSaved] = useState(true); + const [saveTime, setSaveTime] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const tmr = useRef(null); + + useEffect(() => { + api('/dashboard/note').then(d => { setContent(d.content || ''); setSaveTime(d.updated_at); }).catch(() => {}); + }, []); + + const change = val => { + setContent(val); setSaved(false); clearTimeout(tmr.current); + tmr.current = setTimeout(async () => { + try { const r = await api('/dashboard/note', { method:'PUT', body:{ content:val } }); setSaved(true); setSaveTime(r.updated_at); } + catch(e) { toast(e.message, 'error'); } + }, 900); + }; + + return ( +
+ + {isOpen &&
+ {saved ? (saveTime ? `✓ ${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : '✓') : '…'} +
} + {isOpen &&