diff --git a/.env.example b/.env.example deleted file mode 100644 index ff346ca..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# 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 deleted file mode 100644 index aaf8d24..0000000 --- a/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -.env -*.db -*.db-bak -dist/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 6bb801d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# ── 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 deleted file mode 100644 index f7007f5..0000000 --- a/backend/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 9dc4a88..0000000 --- a/backend/src/consoleLog.js +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index b186569..0000000 --- a/backend/src/crypto-helper.js +++ /dev/null @@ -1,36 +0,0 @@ -// ── 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 deleted file mode 100644 index 856b492..0000000 --- a/backend/src/db.js +++ /dev/null @@ -1,710 +0,0 @@ -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 deleted file mode 100644 index 965c657..0000000 --- a/backend/src/index.js +++ /dev/null @@ -1,362 +0,0 @@ -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 deleted file mode 100644 index ae29f2e..0000000 --- a/backend/src/middleware/auth.js +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 2a4320a..0000000 --- a/backend/src/mqtt.js +++ /dev/null @@ -1,620 +0,0 @@ -// ── 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 deleted file mode 100644 index e57d775..0000000 --- a/backend/src/publicAccessLog.js +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 8d25dfb..0000000 --- a/backend/src/pushLog.js +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 83e0b0c..0000000 --- a/backend/src/routes/admin.js +++ /dev/null @@ -1,604 +0,0 @@ -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 deleted file mode 100644 index 59976bc..0000000 --- a/backend/src/routes/auth.js +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index 0562f31..0000000 --- a/backend/src/routes/calendar.js +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 5262f22..0000000 --- a/backend/src/routes/dashboard.js +++ /dev/null @@ -1,330 +0,0 @@ -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 deleted file mode 100644 index 4d6daef..0000000 --- a/backend/src/routes/search.js +++ /dev/null @@ -1,260 +0,0 @@ -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 deleted file mode 100644 index 561875e..0000000 --- a/backend/src/routes/system.js +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index c3efc25..0000000 --- a/backend/src/tools/bestellungen/routes.js +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 1a6658e..0000000 --- a/backend/src/tools/dateien/public-share-public.js +++ /dev/null @@ -1,103 +0,0 @@ -// Ö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 deleted file mode 100644 index e0ac57c..0000000 --- a/backend/src/tools/dateien/public-share.js +++ /dev/null @@ -1,88 +0,0 @@ -// 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 deleted file mode 100644 index baff25b..0000000 --- a/backend/src/tools/dateien/routes.js +++ /dev/null @@ -1,444 +0,0 @@ -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 deleted file mode 100644 index cee0ae3..0000000 --- a/backend/src/tools/dateien/upload-share.js +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index d6565f9..0000000 --- a/backend/src/tools/dateien/webdav.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * 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 deleted file mode 100644 index 97d81e9..0000000 --- a/backend/src/tools/gebietseroberung/routes.js +++ /dev/null @@ -1,617 +0,0 @@ -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 deleted file mode 100644 index 773f13e..0000000 --- a/backend/src/tools/kalkulator3d/routes.js +++ /dev/null @@ -1,191 +0,0 @@ -// 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 deleted file mode 100644 index 5603ebd..0000000 --- a/backend/src/tools/kanban/routes.js +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index 7b516d4..0000000 --- a/backend/src/tools/koepi/routes.js +++ /dev/null @@ -1,954 +0,0 @@ -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 deleted file mode 100644 index 72fa2de..0000000 --- a/backend/src/tools/linkliste/routes.js +++ /dev/null @@ -1,212 +0,0 @@ -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 deleted file mode 100644 index 180880b..0000000 --- a/backend/src/tools/media/routes.js +++ /dev/null @@ -1,1265 +0,0 @@ -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 deleted file mode 100644 index c170973..0000000 --- a/backend/src/tools/nachrichten/routes.js +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 9e41680..0000000 --- a/backend/src/tools/paywallkiller/routes.js +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index c18cff8..0000000 --- a/backend/src/tools/qrcodes/routes.js +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a758566..0000000 --- a/backend/src/tools/schocken/routes.js +++ /dev/null @@ -1,609 +0,0 @@ -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 deleted file mode 100644 index 8897a4c..0000000 --- a/backend/src/tools/snippets/routes.js +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index d5203f7..0000000 --- a/backend/src/tools/statistik/routes.js +++ /dev/null @@ -1,266 +0,0 @@ -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 deleted file mode 100644 index b4fff99..0000000 --- a/backend/src/tools/whiteboard/routes.js +++ /dev/null @@ -1,257 +0,0 @@ -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 deleted file mode 100644 index bf129d6..0000000 --- a/backend/upload-page.html +++ /dev/null @@ -1,238 +0,0 @@ - - - - - -Datei hochladen – DickenDock - - - -
-

📤 Datei hochladen

-

Dateien senden

- -
-
⏳ Link wird geprüft…
- - - - - -
- - -
- - - - diff --git a/build.sh b/build.sh deleted file mode 100644 index 4c198f7..0000000 --- a/build.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/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 deleted file mode 100644 index f4ae04c..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 598cf7d..0000000 --- a/env.example +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 67fb64f..0000000 --- a/frontend/generate-icons.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// 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 deleted file mode 100644 index fa8ccd5..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - Dicken Dock - - -
- - - diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 1935a6d..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index f576035..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png deleted file mode 100644 index fed0dec..0000000 Binary files a/frontend/public/icon-192.png and /dev/null differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png deleted file mode 100644 index 024646e..0000000 Binary files a/frontend/public/icon-512.png and /dev/null differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg deleted file mode 100644 index 8c4cade..0000000 --- a/frontend/public/icon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - DD - DOCK - - diff --git a/frontend/public/koepi/aldi.png b/frontend/public/koepi/aldi.png deleted file mode 100644 index 0d7ee38..0000000 Binary files a/frontend/public/koepi/aldi.png and /dev/null differ diff --git a/frontend/public/koepi/edeka.png b/frontend/public/koepi/edeka.png deleted file mode 100644 index 18a05dd..0000000 Binary files a/frontend/public/koepi/edeka.png and /dev/null differ diff --git a/frontend/public/koepi/hornbach.png b/frontend/public/koepi/hornbach.png deleted file mode 100644 index f1c24b9..0000000 Binary files a/frontend/public/koepi/hornbach.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_033.png b/frontend/public/koepi/kasten_033.png deleted file mode 100644 index 21279ef..0000000 Binary files a/frontend/public/koepi/kasten_033.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_033_05.png b/frontend/public/koepi/kasten_033_05.png deleted file mode 100644 index 57ef7c0..0000000 Binary files a/frontend/public/koepi/kasten_033_05.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_033_055.png b/frontend/public/koepi/kasten_033_055.png deleted file mode 100644 index 57ef7c0..0000000 Binary files a/frontend/public/koepi/kasten_033_055.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_050.png b/frontend/public/koepi/kasten_050.png deleted file mode 100644 index 332b5e5..0000000 Binary files a/frontend/public/koepi/kasten_050.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_11er.png b/frontend/public/koepi/kasten_11er.png deleted file mode 100644 index cbd3235..0000000 Binary files a/frontend/public/koepi/kasten_11er.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_lang.png b/frontend/public/koepi/kasten_lang.png deleted file mode 100644 index cbd3235..0000000 Binary files a/frontend/public/koepi/kasten_lang.png and /dev/null differ diff --git a/frontend/public/koepi/kasten_steini.png b/frontend/public/koepi/kasten_steini.png deleted file mode 100644 index d0aff89..0000000 Binary files a/frontend/public/koepi/kasten_steini.png and /dev/null differ diff --git a/frontend/public/koepi/kaufland.png b/frontend/public/koepi/kaufland.png deleted file mode 100644 index 1fb2efa..0000000 Binary files a/frontend/public/koepi/kaufland.png and /dev/null differ diff --git a/frontend/public/koepi/koepi_platzhalter.png b/frontend/public/koepi/koepi_platzhalter.png deleted file mode 100644 index 3c54c30..0000000 Binary files a/frontend/public/koepi/koepi_platzhalter.png and /dev/null differ diff --git a/frontend/public/koepi/lidl.png b/frontend/public/koepi/lidl.png deleted file mode 100644 index b2d5403..0000000 Binary files a/frontend/public/koepi/lidl.png and /dev/null differ diff --git a/frontend/public/koepi/netto.png b/frontend/public/koepi/netto.png deleted file mode 100644 index 9a2f2cc..0000000 Binary files a/frontend/public/koepi/netto.png and /dev/null differ diff --git a/frontend/public/koepi/netto_getraenkemarkt.png b/frontend/public/koepi/netto_getraenkemarkt.png deleted file mode 100644 index c3e4243..0000000 Binary files a/frontend/public/koepi/netto_getraenkemarkt.png and /dev/null differ diff --git a/frontend/public/koepi/palette_050.png b/frontend/public/koepi/palette_050.png deleted file mode 100644 index d55d609..0000000 Binary files a/frontend/public/koepi/palette_050.png and /dev/null differ diff --git a/frontend/public/koepi/penny.png b/frontend/public/koepi/penny.png deleted file mode 100644 index 18d1d6b..0000000 Binary files a/frontend/public/koepi/penny.png and /dev/null differ diff --git a/frontend/public/koepi/rewe.png b/frontend/public/koepi/rewe.png deleted file mode 100644 index b859df4..0000000 Binary files a/frontend/public/koepi/rewe.png and /dev/null differ diff --git a/frontend/public/koepi/traeger.png b/frontend/public/koepi/traeger.png deleted file mode 100644 index b590c94..0000000 Binary files a/frontend/public/koepi/traeger.png and /dev/null differ diff --git a/frontend/public/koepi/trinkgut.png b/frontend/public/koepi/trinkgut.png deleted file mode 100644 index 4faf727..0000000 Binary files a/frontend/public/koepi/trinkgut.png and /dev/null differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json deleted file mode 100644 index 8b8b433..0000000 --- a/frontend/public/manifest.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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 deleted file mode 100644 index d7580d8..0000000 --- a/frontend/public/sw.js +++ /dev/null @@ -1,55 +0,0 @@ -// 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 deleted file mode 100644 index af132f8..0000000 --- a/frontend/public/vite.config.js +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 42e1717..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,4396 +0,0 @@ -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 &&