generated from Dicken/dickendock
Initial commit
This commit is contained in:
63
backend/src/consoleLog.js
Normal file
63
backend/src/consoleLog.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Persistenter Pfad (selbes Volume wie die DB), übersteht Container-Neustarts
|
||||
const LOG_FILE = process.env.CONSOLE_LOG_FILE || '/data/app-console.log';
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const origLog = console.log.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
|
||||
function stringifyArg(a) {
|
||||
if (typeof a === 'string') return a;
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}
|
||||
|
||||
function localTimestamp() {
|
||||
const d = new Date();
|
||||
const pad = (n, len=2) => String(n).padStart(len, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(),3)}`;
|
||||
}
|
||||
|
||||
function appendLine(level, args) {
|
||||
try {
|
||||
const msg = args.map(stringifyArg).join(' ');
|
||||
fs.appendFileSync(LOG_FILE, `[${localTimestamp()}] [${level}] ${msg}\n`);
|
||||
} catch {
|
||||
// Darf niemals die eigentliche Konsolen-Ausgabe verhindern
|
||||
}
|
||||
}
|
||||
|
||||
// Entfernt Zeilen, die älter als MAX_AGE_MS sind. Zeilen ohne erkennbaren
|
||||
// Zeitstempel (z.B. Fortsetzungszeilen eines mehrzeiligen Stacktraces) werden
|
||||
// bewusst behalten, um nichts mittendrin abzuschneiden.
|
||||
function pruneOldLines() {
|
||||
try {
|
||||
if (!fs.existsSync(LOG_FILE)) return;
|
||||
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
||||
const cutoff = Date.now() - MAX_AGE_MS;
|
||||
const lines = content.split('\n').filter(line => {
|
||||
const m = line.match(/^\[([^\]]+)\]/);
|
||||
if (!m) return true;
|
||||
const t = new Date(m[1].replace(' ', 'T')).getTime();
|
||||
return Number.isNaN(t) || t >= cutoff;
|
||||
});
|
||||
fs.writeFileSync(LOG_FILE, lines.join('\n'));
|
||||
} catch (e) {
|
||||
origError('Konsolen-Log-Aufräumen fehlgeschlagen:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function installConsoleCapture() {
|
||||
console.log = (...args) => { origLog(...args); appendLine('LOG', args); };
|
||||
console.error = (...args) => { origError(...args); appendLine('ERROR', args); };
|
||||
console.warn = (...args) => { origWarn(...args); appendLine('WARN', args); };
|
||||
|
||||
pruneOldLines(); // einmal direkt beim Start
|
||||
setInterval(pruneOldLines, 60 * 60 * 1000); // danach stündlich
|
||||
}
|
||||
|
||||
function getLogFilePath() { return LOG_FILE; }
|
||||
|
||||
module.exports = { installConsoleCapture, getLogFilePath, pruneOldLines };
|
||||
36
backend/src/crypto-helper.js
Normal file
36
backend/src/crypto-helper.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── Verschlüsselungs-Helper für sensible gespeicherte Zugangsdaten ──────────
|
||||
// Nutzt AES-256-GCM. Der Schlüssel wird aus JWT_SECRET abgeleitet (kein
|
||||
// zusätzlicher Env-Var nötig) — ausreichend für den Zweck hier (Zugangsdaten
|
||||
// liegen nicht mehr im Klartext in der SQLite-DB), ersetzt aber kein
|
||||
// dediziertes Secret-Management für hochsensible Fälle.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const KEY = crypto.createHash('sha256')
|
||||
.update(process.env.JWT_SECRET || 'dickendock-fallback-key-bitte-JWT_SECRET-setzen')
|
||||
.digest();
|
||||
|
||||
function encrypt(text) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||
}
|
||||
|
||||
function decrypt(b64) {
|
||||
if (!b64) return null;
|
||||
try {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const enc = buf.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null; // falsches/verändertes Secret oder korrupte Daten
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt };
|
||||
710
backend/src/db.js
Normal file
710
backend/src/db.js
Normal file
@@ -0,0 +1,710 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
|
||||
const db = new Database(process.env.DB_PATH || '/data/dickendock.db');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Dashboard-Widgets
|
||||
CREATE TABLE IF NOT EXISTS quick_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🔗',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tool: 3D-Kalkulator
|
||||
CREATE TABLE IF NOT EXISTS calendar_feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#4ecdc4',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(folder_id, shared_with)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
originalname TEXT NOT NULL,
|
||||
mimetype TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(file_id, shared_with)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
bemerkung TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'warteliste',
|
||||
custom_price REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
calculation_id INTEGER REFERENCES calculations(id) ON DELETE SET NULL,
|
||||
calc_name TEXT NOT NULL,
|
||||
preis_freundschaft REAL NOT NULL,
|
||||
preis_normal REAL NOT NULL,
|
||||
preis_auftrag REAL NOT NULL,
|
||||
stueckzahl INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calculations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
gramm REAL NOT NULL,
|
||||
stunden REAL NOT NULL,
|
||||
farben INTEGER NOT NULL DEFAULT 1,
|
||||
materialpreis_pro_gramm REAL NOT NULL DEFAULT 0.01,
|
||||
stromverbrauch_kw REAL NOT NULL DEFAULT 0.15,
|
||||
strompreis_pro_kwh REAL NOT NULL DEFAULT 0.38,
|
||||
druckerpreis REAL NOT NULL DEFAULT 550,
|
||||
gesamtdruckstunden REAL NOT NULL DEFAULT 5000,
|
||||
verschleiss_pro_stunde REAL NOT NULL DEFAULT 0.06,
|
||||
preis_freundschaft REAL NOT NULL,
|
||||
preis_normal REAL NOT NULL,
|
||||
preis_auftrag REAL NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Standard-Admin beim ersten Start anlegen
|
||||
// Default admin settings
|
||||
const settingDefaults = [
|
||||
['file_max_size_mb', '50'],
|
||||
['file_max_count', '20'],
|
||||
['file_allowed_ext', '.pdf,.jpg,.jpeg,.png,.gif,.zip,.txt,.docx,.xlsx,.mp4,.stl,.3mf'],
|
||||
];
|
||||
for (const [key, value] of settingDefaults) {
|
||||
if (!db.prepare('SELECT key FROM admin_settings WHERE key=?').get(key))
|
||||
db.prepare('INSERT INTO admin_settings (key,value) VALUES (?,?)').run(key, value);
|
||||
}
|
||||
|
||||
if (db.prepare('SELECT COUNT(*) c FROM users').get().c === 0) {
|
||||
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run('admin', bcrypt.hashSync('admin123', 12), 'admin');
|
||||
console.log('✅ Erster Start: Admin angelegt: admin / admin123 → Bitte Passwort sofort ändern!');
|
||||
}
|
||||
|
||||
// ── Migrationen (werden bei jedem Start geprüft) ─────────────────────────────
|
||||
const cols = db.prepare("PRAGMA table_info(calculations)").all().map(r => r.name);
|
||||
if (!cols.includes('image')) db.exec("ALTER TABLE calculations ADD COLUMN image TEXT");
|
||||
if (!cols.includes('bemerkung')) db.exec("ALTER TABLE calculations ADD COLUMN bemerkung TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Migrationen orders
|
||||
const ordCols = db.prepare("PRAGMA table_info(orders)").all().map(r => r.name);
|
||||
if (!ordCols.includes('bezahlt')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt INTEGER NOT NULL DEFAULT 0");
|
||||
if (!ordCols.includes('bezahlt_am')) db.exec("ALTER TABLE orders ADD COLUMN bezahlt_am DATETIME");
|
||||
if (!ordCols.includes('abgeholt')) db.exec("ALTER TABLE orders ADD COLUMN abgeholt INTEGER NOT NULL DEFAULT 0");
|
||||
if (!ordCols.includes('abgeholt_am'))db.exec("ALTER TABLE orders ADD COLUMN abgeholt_am DATETIME");
|
||||
|
||||
// Migrationen order_items
|
||||
// files migrations
|
||||
const fileCols = db.prepare("PRAGMA table_info(files)").all().map(r => r.name);
|
||||
if (!fileCols.includes('folder_id')) db.exec("ALTER TABLE files ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL");
|
||||
|
||||
// folder_shares migrations (only if table exists)
|
||||
const fsTableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='folder_shares'").get();
|
||||
if (fsTableExists) {
|
||||
const fsCols = db.prepare("PRAGMA table_info(folder_shares)").all().map(r => r.name);
|
||||
if (!fsCols.includes('created_at')) db.exec("ALTER TABLE folder_shares ADD COLUMN created_at DATETIME DEFAULT NULL");
|
||||
}
|
||||
|
||||
const oiCols = db.prepare("PRAGMA table_info(order_items)").all().map(r => r.name);
|
||||
if (!oiCols.includes('stunden')) db.exec("ALTER TABLE order_items ADD COLUMN stunden REAL NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('custom_price')) db.exec("ALTER TABLE order_items ADD COLUMN custom_price REAL");
|
||||
if (!oiCols.includes('status')) db.exec("ALTER TABLE order_items ADD COLUMN status TEXT NOT NULL DEFAULT 'warteliste'");
|
||||
if (!oiCols.includes('qty_warteliste')) db.exec("ALTER TABLE order_items ADD COLUMN qty_warteliste INTEGER NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('qty_in_arbeit')) db.exec("ALTER TABLE order_items ADD COLUMN qty_in_arbeit INTEGER NOT NULL DEFAULT 0");
|
||||
if (!oiCols.includes('qty_fertig')) db.exec("ALTER TABLE order_items ADD COLUMN qty_fertig INTEGER NOT NULL DEFAULT 0");
|
||||
// Bestehende Einträge: alle Stücke auf Warteliste setzen falls noch keine Qtys gesetzt
|
||||
db.exec("UPDATE order_items SET qty_warteliste=stueckzahl WHERE qty_warteliste=0 AND qty_in_arbeit=0 AND qty_fertig=0");
|
||||
|
||||
// Migration: file_shares – Passwort und Zugriffszeit
|
||||
const fShareCols = db.prepare("PRAGMA table_info(file_shares)").all().map(r => r.name);
|
||||
if (fShareCols.length && !fShareCols.includes('password_hash'))
|
||||
db.exec("ALTER TABLE file_shares ADD COLUMN password_hash TEXT DEFAULT NULL");
|
||||
if (fShareCols.length && !fShareCols.includes('accessed_at'))
|
||||
db.exec("ALTER TABLE file_shares ADD COLUMN accessed_at DATETIME DEFAULT NULL");
|
||||
|
||||
// ── Nachrichten-Migrationen ───────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_content TEXT NOT NULL,
|
||||
iv TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
read_by_recipient INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_keys'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE user_keys (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pushover_settings'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE pushover_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_key TEXT NOT NULL,
|
||||
app_token TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: Soft-Delete-Spalten entfernen (nur Nachrichten übernehmen die noch sichtbar waren)
|
||||
const msgCols = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
|
||||
if (msgCols.includes('deleted_by_sender')) {
|
||||
db.exec(`
|
||||
CREATE TABLE messages_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_content TEXT NOT NULL,
|
||||
iv TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
read_by_recipient INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO messages_v2 (id, sender_id, recipient_id, encrypted_content, iv, created_at, read_by_recipient)
|
||||
SELECT id, sender_id, recipient_id, encrypted_content, iv, created_at,
|
||||
COALESCE(read_by_recipient, 0)
|
||||
FROM messages
|
||||
WHERE deleted_by_sender = 0 AND deleted_by_recipient = 0;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_v2 RENAME TO messages;
|
||||
`);
|
||||
console.log('✅ Migration: messages → Hard-Delete (Soft-Delete-Spalten entfernt)');
|
||||
}
|
||||
// Falls read_by_recipient noch fehlt (sehr alte Installation)
|
||||
const msgCols2 = db.prepare("PRAGMA table_info(messages)").all().map(r => r.name);
|
||||
if (msgCols2.length && !msgCols2.includes('read_by_recipient')) {
|
||||
db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
// User-Preferences (JSON-Blob)
|
||||
const userCols2 = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
|
||||
if (!userCols2.includes('preferences'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT NULL");
|
||||
if (!userCols2.includes('last_active_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_active_at DATETIME DEFAULT NULL");
|
||||
if (!userCols2.includes('hidden'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
|
||||
if (!userCols2.includes('chat_active_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL");
|
||||
if (!userCols2.includes('hidden_tools'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
// ── Login-Sicherheit ──────────────────────────────────────────────────────────
|
||||
// Spalten für Account-Lockout in users-Tabelle
|
||||
const userCols = db.prepare("PRAGMA table_info(users)").all().map(r => r.name);
|
||||
if (!userCols.includes('failed_attempts'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0");
|
||||
if (!userCols.includes('locked_until'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN locked_until DATETIME DEFAULT NULL");
|
||||
if (!userCols.includes('last_failed_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN last_failed_at DATETIME DEFAULT NULL");
|
||||
|
||||
// Login-Fehlversuche Log
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='login_attempts'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Default-Einstellungen für Login-Schutz
|
||||
const loginDefaults = [
|
||||
['login_max_attempts', '5'],
|
||||
['login_lockout_minutes', '30'],
|
||||
];
|
||||
for (const [k, v] of loginDefaults) {
|
||||
if (!db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k))
|
||||
db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v);
|
||||
}
|
||||
|
||||
// ── Ideen-Board ───────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE board_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
promoted_from_wish INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: promoted_from_wish Spalte
|
||||
const boardCols = db.prepare("PRAGMA table_info(board_items)").all().map(r => r.name);
|
||||
if (boardCols.length && !boardCols.includes('promoted_from_wish'))
|
||||
db.exec("ALTER TABLE board_items ADD COLUMN promoted_from_wish INTEGER NOT NULL DEFAULT 0");
|
||||
if (boardCols.length && !boardCols.includes('promoted_at'))
|
||||
db.exec("ALTER TABLE board_items ADD COLUMN promoted_at DATETIME DEFAULT NULL");
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE board_reads (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Migration: Pushover retry/expire
|
||||
const poCols = db.prepare("PRAGMA table_info(pushover_settings)").all().map(r => r.name);
|
||||
if (poCols.length && !poCols.includes('retry'))
|
||||
db.exec("ALTER TABLE pushover_settings ADD COLUMN retry INTEGER DEFAULT NULL");
|
||||
if (poCols.length && !poCols.includes('expire'))
|
||||
db.exec("ALTER TABLE pushover_settings ADD COLUMN expire INTEGER DEFAULT NULL");
|
||||
|
||||
// ── Changelog ─────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE changelog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
build_time TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// Migration: build_time Spalte
|
||||
const clCols = db.prepare("PRAGMA table_info(changelog)").all().map(r => r.name);
|
||||
if (clCols.length && !clCols.includes('build_time'))
|
||||
db.exec("ALTER TABLE changelog ADD COLUMN build_time TEXT DEFAULT NULL");
|
||||
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='changelog_reads'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE changelog_reads (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE push_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
message TEXT NOT NULL,
|
||||
scheduled_at DATETIME NOT NULL,
|
||||
sent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Liste ────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🔗',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_id INTEGER NOT NULL REFERENCES link_list(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(link_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Kalkulator-Shares ─────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE calculation_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
calc_id INTEGER NOT NULL REFERENCES calculations(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(calc_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Code-Schnipsel ────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='snippets'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT 'text',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
updated_at DATETIME DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE snippet_tags (
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (snippet_id, tag)
|
||||
);
|
||||
CREATE TABLE snippet_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'text',
|
||||
saved_at DATETIME DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE snippet_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(snippet_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Liste Ordner + Erweiterungen ─────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_folders'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_list_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📁',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
in_quickaccess INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
// link_list Spalten nachrüsten
|
||||
{
|
||||
const cols = db.pragma('table_info(link_list)').map(c => c.name);
|
||||
if (!cols.includes('folder_id')) db.exec('ALTER TABLE link_list ADD COLUMN folder_id INTEGER DEFAULT NULL');
|
||||
if (!cols.includes('sort_order')) db.exec('ALTER TABLE link_list ADD COLUMN sort_order INTEGER DEFAULT 0');
|
||||
if (!cols.includes('in_quickaccess')) db.exec('ALTER TABLE link_list ADD COLUMN in_quickaccess INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
|
||||
// ── QR-Codes ──────────────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='qr_codes'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE qr_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 256,
|
||||
fg_color TEXT NOT NULL DEFAULT '#000000',
|
||||
bg_color TEXT NOT NULL DEFAULT '#ffffff',
|
||||
margin INTEGER NOT NULL DEFAULT 4,
|
||||
dot_style TEXT NOT NULL DEFAULT 'square',
|
||||
corner_style TEXT NOT NULL DEFAULT 'square',
|
||||
caption TEXT NOT NULL DEFAULT '',
|
||||
caption_pos TEXT NOT NULL DEFAULT 'bottom',
|
||||
caption_color TEXT NOT NULL DEFAULT '#000000',
|
||||
caption_size INTEGER NOT NULL DEFAULT 14,
|
||||
created_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Link-Folder Shares ────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_folder_shares'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE link_folder_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES link_list_folders(id) ON DELETE CASCADE,
|
||||
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(folder_id, shared_with)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Kalender-Event-Cache für Suche ────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_event_cache'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE calendar_event_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feed_id INTEGER NOT NULL REFERENCES calendar_feeds(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
start_dt TEXT,
|
||||
end_dt TEXT,
|
||||
synced_at DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// short_url auf upload_shares
|
||||
{
|
||||
const cols = db.pragma('table_info(upload_shares)').map(c => c.name);
|
||||
if (!cols.includes('short_url'))
|
||||
db.exec('ALTER TABLE upload_shares ADD COLUMN short_url TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
|
||||
// ── Movie-Favoriten ───────────────────────────────────────────────────────────
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='movie_favorites'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE movie_favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tmdb_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
poster_path TEXT NOT NULL DEFAULT '',
|
||||
release_date_de TEXT NOT NULL DEFAULT '',
|
||||
added_at DATETIME DEFAULT NULL,
|
||||
UNIQUE(user_id, tmdb_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// movie_favorites neue Spalten (Migration)
|
||||
{
|
||||
const cols = db.pragma('table_info(movie_favorites)').map(c => c.name);
|
||||
if (!cols.includes('release_date'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''");
|
||||
if (!cols.includes('genres'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'");
|
||||
if (!cols.includes('fsk'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''");
|
||||
if (!cols.includes('acknowledged'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged INTEGER NOT NULL DEFAULT 0");
|
||||
if (!cols.includes('acknowledged_at'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN acknowledged_at DATETIME DEFAULT NULL");
|
||||
if (!cols.includes('user_notified'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN user_notified INTEGER NOT NULL DEFAULT 0");
|
||||
if (!cols.includes('media_type'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN media_type TEXT NOT NULL DEFAULT 'movie'");
|
||||
if (!cols.includes('admin_seen'))
|
||||
db.exec("ALTER TABLE movie_favorites ADD COLUMN admin_seen INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
// xREL Badge Cache (persistent, überlebt Container-Restarts)
|
||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xrel_cache'").get()) {
|
||||
db.exec(`
|
||||
CREATE TABLE xrel_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// TMDb-Token Default
|
||||
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
||||
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
||||
|
||||
// Öffentliche Datei/Ordner-Freigabe Links
|
||||
// CREATE TABLE IF NOT EXISTS – sicher bei jedem Start
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS public_file_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
download_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
// 3D-Druck Ausgaben (Haushaltsbuch)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein
|
||||
// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon
|
||||
// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein
|
||||
// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS makerworld_stats (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data_json TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Log aller verschickten Pushover-Nachrichten (für Admin-Übersicht "Logs")
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
success INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// KöPi: Whitelist der exakt gewünschten Filialen (echte marktguru Store-IDs,
|
||||
// aufgelöst aus bestätigten Prospekt-Links — siehe koepi/routes.js)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS koepi_local_stores (
|
||||
store_id INTEGER PRIMARY KEY,
|
||||
retailer TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
resolved_at DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
// Besuche öffentlicher, loginfreier Links (KöPi-Teilen-Link, ggf. künftig
|
||||
// weitere) — für die Admin-Log-Übersicht
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS public_access_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_type TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME
|
||||
)
|
||||
`);
|
||||
{
|
||||
const cols = db.pragma('table_info(public_access_log)').map(c => c.name);
|
||||
if (!cols.includes('device'))
|
||||
db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''");
|
||||
if (!cols.includes('link_name'))
|
||||
db.exec("ALTER TABLE public_access_log ADD COLUMN link_name TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
// KöPi: benannte, öffentliche Teilen-Links (mehrere gleichzeitig möglich,
|
||||
// jeweils mit eigenem Namen wie "Sina") statt nur einem einzigen globalen Link
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS koepi_share_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME
|
||||
)
|
||||
`);
|
||||
// Migration: den bisherigen einzelnen Link (war für Sina gedacht) einmalig in
|
||||
// die neue Tabelle übernehmen, statt ihn ungültig zu machen
|
||||
{
|
||||
const oldToken = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_share_token'").get()?.value;
|
||||
if (oldToken) {
|
||||
const already = db.prepare('SELECT 1 FROM koepi_share_links WHERE token=?').get(oldToken);
|
||||
if (!already) {
|
||||
db.prepare(`
|
||||
INSERT INTO koepi_share_links (token, name, created_at)
|
||||
VALUES (?, 'Sina', datetime('now','localtime'))
|
||||
`).run(oldToken);
|
||||
}
|
||||
db.prepare("DELETE FROM admin_settings WHERE key='koepi_share_token'").run();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
362
backend/src/index.js
Normal file
362
backend/src/index.js
Normal file
@@ -0,0 +1,362 @@
|
||||
require('./consoleLog').installConsoleCapture();
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
require('./db');
|
||||
const { logPush } = require('./pushLog');
|
||||
|
||||
const app = express();
|
||||
// Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol
|
||||
// immer "http", auch wenn der Nutzer über https zugreift (z.B. wichtig für
|
||||
// das MakerWorld-Bookmarklet: sonst mixed-content-blockiert der Browser den
|
||||
// fetch()-Call stillschweigend, weil die generierte Origin auf http:// zeigt)
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ── Aktivitäts-Tracking: nur bei echten Aktionen (POST/PUT/DELETE) ─────────────
|
||||
app.use((req, res, next) => {
|
||||
if (['POST','PUT','DELETE'].includes(req.method)) {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const p = jwt.verify(auth.slice(7), process.env.JWT_SECRET || 'dev-secret');
|
||||
if (p?.id) {
|
||||
// Prüfen ob User vorher >15 Min inaktiv war → "gerade online gegangen"
|
||||
const user = db.prepare('SELECT last_active_at, role FROM users WHERE id=?').get(p.id);
|
||||
const wasInactive = !user?.last_active_at ||
|
||||
(Date.now() - new Date(user.last_active_at).getTime()) > 15 * 60 * 1000;
|
||||
|
||||
db.prepare("UPDATE users SET last_active_at=datetime('now','localtime') WHERE id=?").run(p.id);
|
||||
|
||||
// Pushover an alle Admins (außer wenn User selbst Admin ist)
|
||||
if (wasInactive && user?.role !== 'admin') {
|
||||
const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand';
|
||||
const admins = db.prepare(`
|
||||
SELECT p.user_id, p.user_key, p.app_token FROM pushover_settings p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL
|
||||
`).all();
|
||||
for (const admin of admins) {
|
||||
const title = '👤 DickenDock';
|
||||
const message = `${username} ist gerade online gegangen.`;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: admin.app_token,
|
||||
user: admin.user_key,
|
||||
title,
|
||||
message,
|
||||
priority: -1,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
logPush({ userId: admin.user_id, title, message, priority: -1, source: 'presence' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Security Headers ──────────────────────────────────────────────────────────
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
// unsafe-inline nötig für React inline-styles + @import Google Fonts
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
|
||||
"connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000",
|
||||
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
|
||||
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
|
||||
"worker-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-src https://archive.ph https://archive.is https://archive.today",
|
||||
].join('; '));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/admin', require('./routes/admin'));
|
||||
app.use('/api/calendar', require('./routes/calendar'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
app.use('/api/system', require('./routes/system'));
|
||||
|
||||
// Öffentliche Datei-Share Verwaltung (vor Auto-Loader damit /:id nicht matcht)
|
||||
app.use('/api/tools/dateien/public-shares', require('./tools/dateien/public-share'));
|
||||
|
||||
// Tool-Routen automatisch laden
|
||||
const toolsDir = path.join(__dirname, 'tools');
|
||||
fs.readdirSync(toolsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.forEach(d => {
|
||||
const routeFile = path.join(toolsDir, d.name, 'routes.js');
|
||||
if (fs.existsSync(routeFile)) {
|
||||
app.use(`/api/tools/${d.name}`, require(routeFile));
|
||||
console.log(` 🔧 Tool geladen: ${d.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
|
||||
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
|
||||
app.use('/api/upload-shares', require('./tools/dateien/upload-share'));
|
||||
app.use('/api/public/file-share', require('./tools/dateien/public-share-public'));
|
||||
app.use('/api/search', require('./routes/search'));
|
||||
|
||||
// Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst
|
||||
app.get('/u/:token', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache');
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
// Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst
|
||||
app.get('/s/:token', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache');
|
||||
res.sendFile(require('path').join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
const PUBLIC = path.join(__dirname, '../public');
|
||||
|
||||
app.get('/manifest.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'manifest.json'));
|
||||
});
|
||||
app.get('/sw.js', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('Service-Worker-Allowed', '/');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'sw.js'));
|
||||
});
|
||||
|
||||
app.use(express.static(PUBLIC, {
|
||||
setHeaders: (res, filePath) => {
|
||||
// JS/CSS Assets haben Hash im Namen → lang cachen
|
||||
if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// index.html nie cachen
|
||||
app.get('*', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.sendFile(path.join(PUBLIC, 'index.html'));
|
||||
});
|
||||
|
||||
// Build-Zeit Endpoint – Frontend prüft ob es eine neue Version gibt
|
||||
app.get('/api/build-time', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
try {
|
||||
const ver = require('fs').readFileSync(path.join(__dirname, '../version.txt'), 'utf8').trim();
|
||||
res.json({ buildTime: ver });
|
||||
} catch { res.json({ buildTime: 'unknown' }); }
|
||||
});
|
||||
|
||||
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
|
||||
const http = require('http');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws/whiteboard' });
|
||||
|
||||
// Raum-Map: whiteboard_id → Set<ws>
|
||||
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`);
|
||||
16
backend/src/middleware/auth.js
Normal file
16
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
|
||||
function authenticate(req, res, next) {
|
||||
const h = req.headers.authorization;
|
||||
if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
try { req.user = jwt.verify(h.slice(7), SECRET); next(); }
|
||||
catch { res.status(401).json({ error: 'Token ungültig' }); }
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireAdmin };
|
||||
620
backend/src/mqtt.js
Normal file
620
backend/src/mqtt.js
Normal file
@@ -0,0 +1,620 @@
|
||||
// ── Home Assistant MQTT Integration ──────────────────────────────────────────
|
||||
// Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten
|
||||
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
|
||||
// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT".
|
||||
//
|
||||
// Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KöPi", "Media",
|
||||
// "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert
|
||||
// untereinander stehen.
|
||||
//
|
||||
// Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt
|
||||
// der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops).
|
||||
|
||||
const mqtt = require('mqtt');
|
||||
const db = require('./db');
|
||||
|
||||
const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock';
|
||||
const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`;
|
||||
const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`;
|
||||
const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`;
|
||||
const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set
|
||||
const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`;
|
||||
|
||||
const DEVICE = {
|
||||
identifiers: ['dickendock'],
|
||||
name: 'DickenDock',
|
||||
manufacturer: 'Flo',
|
||||
model: 'DockStation',
|
||||
};
|
||||
|
||||
let client = null;
|
||||
let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||
let mediaAckHandler = null; // (id) => void — einzelnen Favoriten quittieren
|
||||
let mediaAckAllHandler = null; // () => void — alle Favoriten quittieren
|
||||
|
||||
// Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten
|
||||
// wenn ein Favorit quittiert/gelöscht wurde und der Button verschwinden soll)
|
||||
let publishedMediaAckIds = new Set();
|
||||
|
||||
function connectMqtt() {
|
||||
if (!process.env.MQTT_HOST) {
|
||||
console.log('MQTT: MQTT_HOST nicht gesetzt – Home-Assistant-Integration deaktiviert.');
|
||||
return;
|
||||
}
|
||||
|
||||
client = mqtt.connect({
|
||||
host: process.env.MQTT_HOST,
|
||||
port: parseInt(process.env.MQTT_PORT || '1883', 10),
|
||||
username: process.env.MQTT_USERNAME,
|
||||
password: process.env.MQTT_PASSWORD,
|
||||
will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true },
|
||||
reconnectPeriod: 5000,
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`);
|
||||
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
|
||||
publishDiscovery();
|
||||
client.subscribe(KOEPI_CHECK_CMD_TOPIC);
|
||||
client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
|
||||
client.subscribe(MEDIA_ACK_SUBSCRIBE);
|
||||
});
|
||||
|
||||
client.on('message', async (topic) => {
|
||||
try {
|
||||
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
|
||||
await koepiCheckHandler();
|
||||
} else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') {
|
||||
await mediaAckAllHandler();
|
||||
} else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') {
|
||||
const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, '');
|
||||
if (id) await mediaAckHandler(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('MQTT Command-Fehler:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (e) => console.error('MQTT Fehler:', e.message));
|
||||
}
|
||||
|
||||
function publishDiscovery() {
|
||||
if (!client) return;
|
||||
const entities = [
|
||||
// ── KöPi ──────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_koepi_angebot', {
|
||||
name: 'KöPi Angebote',
|
||||
unique_id: 'dickendock_koepi_bestes_angebot',
|
||||
state_topic: `${BASE_TOPIC}/koepi/state`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`,
|
||||
icon: 'mdi:beer',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['binary_sensor', 'dickendock_koepi_changed', {
|
||||
name: 'KöPi Angebot geändert',
|
||||
unique_id: 'dickendock_koepi_changed',
|
||||
state_topic: `${BASE_TOPIC}/koepi/changed`,
|
||||
payload_on: 'ON',
|
||||
payload_off: 'OFF',
|
||||
icon: 'mdi:bell-alert',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['button', 'dickendock_koepi_check', {
|
||||
name: 'KöPi Check jetzt',
|
||||
unique_id: 'dickendock_koepi_check',
|
||||
command_topic: KOEPI_CHECK_CMD_TOPIC,
|
||||
icon: 'mdi:refresh',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_koepi_start', {
|
||||
name: 'KöPi Angebot Start',
|
||||
unique_id: 'dickendock_koepi_start',
|
||||
state_topic: `${BASE_TOPIC}/koepi/start`,
|
||||
device_class: 'date',
|
||||
icon: 'mdi:calendar-start',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_koepi_ende', {
|
||||
name: 'KöPi Angebot Ende',
|
||||
unique_id: 'dickendock_koepi_ende',
|
||||
state_topic: `${BASE_TOPIC}/koepi/ende`,
|
||||
device_class: 'date',
|
||||
icon: 'mdi:calendar-end',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Media ─────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_media_anfragen', {
|
||||
name: 'Media Anfragen offen',
|
||||
unique_id: 'dickendock_media_anfragen',
|
||||
state_topic: `${BASE_TOPIC}/media/anfragen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`,
|
||||
icon: 'mdi:movie-open-star',
|
||||
unit_of_measurement: 'Anfragen',
|
||||
state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
['button', 'dickendock_media_ack_all', {
|
||||
name: 'Media Alle quittieren',
|
||||
unique_id: 'dickendock_media_ack_all',
|
||||
command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
|
||||
icon: 'mdi:check-all',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── 3D ────────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_3d_umsatz_monat', {
|
||||
name: '3D Umsatz Monat',
|
||||
unique_id: 'dickendock_3ddruck_umsatz_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
|
||||
icon: 'mdi:cash-multiple',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_umsatz_gesamt', {
|
||||
name: '3D Umsatz Gesamt',
|
||||
unique_id: 'dickendock_3d_umsatz_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`,
|
||||
icon: 'mdi:cash-multiple',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_grundkosten_monat', {
|
||||
name: '3D Grundkosten Monat',
|
||||
unique_id: 'dickendock_3d_grundkosten_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`,
|
||||
icon: 'mdi:currency-eur-off',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_grundkosten_gesamt', {
|
||||
name: '3D Grundkosten Gesamt',
|
||||
unique_id: 'dickendock_3d_grundkosten_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`,
|
||||
icon: 'mdi:currency-eur-off',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_rohgewinn_monat', {
|
||||
name: '3D Rohgewinn Monat',
|
||||
unique_id: 'dickendock_3d_rohgewinn_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`,
|
||||
icon: 'mdi:chart-line',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_rohgewinn_gesamt', {
|
||||
name: '3D Rohgewinn Gesamt',
|
||||
unique_id: 'dickendock_3d_rohgewinn_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`,
|
||||
icon: 'mdi:chart-line',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_ausgaben_monat', {
|
||||
name: '3D Ausgaben Monat',
|
||||
unique_id: 'dickendock_3d_ausgaben_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`,
|
||||
icon: 'mdi:receipt-text-minus',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_ausgaben_gesamt', {
|
||||
name: '3D Ausgaben Gesamt',
|
||||
unique_id: 'dickendock_3d_ausgaben_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`,
|
||||
icon: 'mdi:receipt-text-minus',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_nettogewinn_monat', {
|
||||
name: '3D Nettogewinn Monat',
|
||||
unique_id: 'dickendock_3d_nettogewinn_monat',
|
||||
state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`,
|
||||
icon: 'mdi:cash-check',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_nettogewinn_gesamt', {
|
||||
name: '3D Nettogewinn Gesamt',
|
||||
unique_id: 'dickendock_3d_nettogewinn_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`,
|
||||
icon: 'mdi:cash-check',
|
||||
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_offen', {
|
||||
name: '3D Bestellungen offen',
|
||||
unique_id: 'dickendock_bestellungen_offen',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/druck/bestellungen_offen_attributes`,
|
||||
icon: 'mdi:cube-send',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_warteliste', {
|
||||
name: '3D Bestellungen Warteliste',
|
||||
unique_id: 'dickendock_3d_bestellungen_warteliste',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`,
|
||||
icon: 'mdi:timer-sand',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_in_arbeit', {
|
||||
name: '3D Bestellungen In Arbeit',
|
||||
unique_id: 'dickendock_3d_bestellungen_in_arbeit',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`,
|
||||
icon: 'mdi:printer-3d-nozzle',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_fertig', {
|
||||
name: '3D Bestellungen Fertig',
|
||||
unique_id: 'dickendock_3d_bestellungen_fertig',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`,
|
||||
icon: 'mdi:check-circle-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_bezahlt', {
|
||||
name: '3D Bestellungen Bezahlt',
|
||||
unique_id: 'dickendock_3d_bestellungen_bezahlt',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`,
|
||||
icon: 'mdi:cash-check',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_abgeschlossen', {
|
||||
name: '3D Bestellungen Abgeschlossen',
|
||||
unique_id: 'dickendock_3d_bestellungen_abgeschlossen',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`,
|
||||
icon: 'mdi:archive-check-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
['sensor', 'dickendock_3d_bestellungen_gesamt', {
|
||||
name: '3D Bestellungen Gesamt',
|
||||
unique_id: 'dickendock_3d_bestellungen_gesamt',
|
||||
state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`,
|
||||
icon: 'mdi:cube-outline',
|
||||
unit_of_measurement: 'Bestellungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_dashboard_push_erinnerungen', {
|
||||
name: 'Dashboard Push-Erinnerungen offen',
|
||||
unique_id: 'dickendock_dashboard_push_erinnerungen',
|
||||
state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`,
|
||||
icon: 'mdi:bell-ring-outline',
|
||||
unit_of_measurement: 'Erinnerungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Kanban ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_kanban_karten', {
|
||||
name: 'Kanban Karten offen',
|
||||
unique_id: 'dickendock_kanban_karten',
|
||||
state_topic: `${BASE_TOPIC}/kanban/karten`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`,
|
||||
icon: 'mdi:view-column-outline',
|
||||
unit_of_measurement: 'Karten', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── System ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_system_online', {
|
||||
name: 'System Nutzer online',
|
||||
unique_id: 'dickendock_nutzer_online',
|
||||
state_topic: `${BASE_TOPIC}/presence/state`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/presence/attributes`,
|
||||
icon: 'mdi:account-multiple',
|
||||
unit_of_measurement: 'Nutzer',
|
||||
state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}],
|
||||
];
|
||||
for (const [component, objectId, payload] of entities) {
|
||||
client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true });
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n) { return Math.round((n || 0) * 100) / 100; }
|
||||
|
||||
function parsePrice(str) {
|
||||
if (!str) return Infinity;
|
||||
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
|
||||
return Number.isNaN(n) ? Infinity : n;
|
||||
}
|
||||
|
||||
// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel.
|
||||
// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur
|
||||
// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen).
|
||||
function parseGermanDate(str, ref = new Date()) {
|
||||
const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/);
|
||||
if (!m) return null;
|
||||
const day = parseInt(m[1], 10);
|
||||
const month = parseInt(m[2], 10) - 1;
|
||||
let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear();
|
||||
const d = new Date(year, month, day);
|
||||
if (!m[3]) {
|
||||
const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth());
|
||||
if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint
|
||||
else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin
|
||||
// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum)
|
||||
function extractDateRange(offers) {
|
||||
let start = null, end = null;
|
||||
for (const o of offers) {
|
||||
const text = o.dateRange || '';
|
||||
const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]);
|
||||
if (found.length >= 2) {
|
||||
const d1 = parseGermanDate(found[0]);
|
||||
const d2 = parseGermanDate(found[1]);
|
||||
if (d1 && (!start || d1 < start)) start = d1;
|
||||
if (d2 && (!end || d2 > end)) end = d2;
|
||||
} else if (found.length === 1) {
|
||||
const d1 = parseGermanDate(found[0]);
|
||||
if (d1 && (!end || d1 > end)) end = d1;
|
||||
}
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function toIsoDate(d) {
|
||||
if (!d) return null;
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
|
||||
function publishKoepiState({ offers, changed }) {
|
||||
if (!client?.connected) return;
|
||||
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||||
|
||||
// State: nur die Anzahl (bleibt immer kurz und stabil) — die eigentlichen
|
||||
// Angebote stehen vollständig in den Attributen
|
||||
const state = list.length ? `${list.length} Angebote` : 'keine Angebote';
|
||||
|
||||
// Lesbarer Mehrzeiler fürs Dashboard, zusätzlich zur strukturierten Liste
|
||||
const listeText = list.length
|
||||
? list.map(o => `${o.retailer}: ${o.price}${o.dateRange ? ' (' + o.dateRange + ')' : ''}`).join('\n')
|
||||
: 'keine Angebote';
|
||||
|
||||
client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({
|
||||
angebote: list,
|
||||
liste_text: listeText,
|
||||
anzahl: list.length,
|
||||
letzter_check: new Date().toISOString(),
|
||||
}), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true });
|
||||
|
||||
const { start, end } = extractDateRange(list);
|
||||
client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler in index.js aufgerufen
|
||||
function publishPresence() {
|
||||
if (!client?.connected) return;
|
||||
const alleNutzer = db.prepare(`
|
||||
SELECT username, last_active_at,
|
||||
CASE WHEN last_active_at > datetime('now','localtime','-5 minutes') THEN 1 ELSE 0 END AS online
|
||||
FROM users
|
||||
ORDER BY last_active_at DESC
|
||||
`).all();
|
||||
const online = alleNutzer.filter(u => !!u.online);
|
||||
|
||||
client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({
|
||||
online: online.map(u => ({ benutzer: u.username, zuletzt_aktiv: u.last_active_at })),
|
||||
alle_nutzer: alleNutzer.map(u => ({
|
||||
benutzer: u.username,
|
||||
zuletzt_aktiv: u.last_active_at,
|
||||
online: !!u.online,
|
||||
})),
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
|
||||
// Offene (unquittierte) Media-Favoriten, systemweit über alle Nutzer.
|
||||
// Legt zusätzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an
|
||||
// und entfernt Buttons für Anfragen, die inzwischen quittiert/gelöscht wurden.
|
||||
function publishMediaAnfragen() {
|
||||
if (!client?.connected) return;
|
||||
const open = db.prepare(`
|
||||
SELECT f.id, f.title, f.added_at, u.username
|
||||
FROM movie_favorites f JOIN users u ON u.id = f.user_id
|
||||
WHERE f.acknowledged = 0
|
||||
ORDER BY f.added_at DESC
|
||||
`).all();
|
||||
|
||||
client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
|
||||
|
||||
const currentIds = new Set(open.map(f => String(f.id)));
|
||||
|
||||
// Neue Buttons für neu hinzugekommene offene Anfragen anlegen
|
||||
for (const fav of open) {
|
||||
const id = String(fav.id);
|
||||
if (!publishedMediaAckIds.has(id)) {
|
||||
const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255);
|
||||
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({
|
||||
name: label,
|
||||
unique_id: `dickendock_media_ack_${id}`,
|
||||
command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`,
|
||||
icon: 'mdi:check-circle-outline',
|
||||
availability_topic: AVAILABILITY_TOPIC,
|
||||
device: DEVICE,
|
||||
}), { retain: true });
|
||||
}
|
||||
}
|
||||
// Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload)
|
||||
for (const id of publishedMediaAckIds) {
|
||||
if (!currentIds.has(id)) {
|
||||
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true });
|
||||
}
|
||||
}
|
||||
publishedMediaAckIds = currentIds;
|
||||
}
|
||||
|
||||
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
|
||||
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
|
||||
function getOrderRevenue(order) {
|
||||
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s
|
||||
FROM order_items WHERE order_id=? AND custom_price IS NOT NULL
|
||||
`).get(order.id);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
|
||||
function getOrderBaseCost(orderId) {
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).get(orderId);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — umfangreiche 3D-Druck-Statistik,
|
||||
// systemweit über alle Nutzer, jeweils für den laufenden Monat und komplett (all-time)
|
||||
function publishDruckStatistik() {
|
||||
if (!client?.connected) return;
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
|
||||
const allOrders = db.prepare('SELECT * FROM orders').all();
|
||||
const paidAll = allOrders.filter(o => !!o.bezahlt);
|
||||
const paidMonat = paidAll.filter(o => {
|
||||
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
|
||||
return d >= monatsAnfang && d <= heute;
|
||||
});
|
||||
|
||||
const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||
const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||
const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
|
||||
const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0);
|
||||
const rohgewinnGesamt = umsatzGesamt - kostenGesamt;
|
||||
const rohgewinnMonat = umsatzMonat - kostenMonat;
|
||||
|
||||
const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s;
|
||||
const ausgabenMonat = db.prepare(
|
||||
'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?'
|
||||
).get(monatsAnfang, heute).s;
|
||||
|
||||
const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
|
||||
const nettoMonat = rohgewinnMonat - ausgabenMonat;
|
||||
|
||||
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
|
||||
for (const o of allOrders) {
|
||||
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
|
||||
else if (o.bezahlt) byStatus.bezahlt++;
|
||||
else if (o.status in byStatus) byStatus[o.status]++;
|
||||
}
|
||||
const offen = allOrders.length - byStatus.abgeschlossen;
|
||||
|
||||
// Konkrete offene Bestellungen (nicht abgeschlossen) mit Besitzer, für die Attribute
|
||||
const offeneBestellungen = db.prepare(`
|
||||
SELECT o.id, o.name, o.status, o.bezahlt, o.abgeholt, o.created_at, u.username
|
||||
FROM orders o JOIN users u ON u.id = o.user_id
|
||||
WHERE NOT (o.bezahlt = 1 AND o.abgeholt = 1)
|
||||
ORDER BY o.created_at DESC
|
||||
`).all();
|
||||
|
||||
const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true });
|
||||
pub('druck/umsatz_monat', round2(umsatzMonat));
|
||||
pub('druck/umsatz_gesamt', round2(umsatzGesamt));
|
||||
pub('druck/grundkosten_monat', round2(kostenMonat));
|
||||
pub('druck/grundkosten_gesamt', round2(kostenGesamt));
|
||||
pub('druck/rohgewinn_monat', round2(rohgewinnMonat));
|
||||
pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt));
|
||||
pub('druck/ausgaben_monat', round2(ausgabenMonat));
|
||||
pub('druck/ausgaben_gesamt', round2(ausgabenGesamt));
|
||||
pub('druck/nettogewinn_monat', round2(nettoMonat));
|
||||
pub('druck/nettogewinn_gesamt', round2(nettoGesamt));
|
||||
pub('druck/bestellungen_offen', offen);
|
||||
client.publish(`${BASE_TOPIC}/druck/bestellungen_offen_attributes`, JSON.stringify({
|
||||
bestellungen: offeneBestellungen,
|
||||
}), { retain: true });
|
||||
pub('druck/bestellungen_warteliste', byStatus.warteliste);
|
||||
pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit);
|
||||
pub('druck/bestellungen_fertig', byStatus.fertig);
|
||||
pub('druck/bestellungen_bezahlt', byStatus.bezahlt);
|
||||
pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen);
|
||||
pub('druck/bestellungen_gesamt', allOrders.length);
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete,
|
||||
// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit
|
||||
function publishPushErinnerungen() {
|
||||
if (!client?.connected) return;
|
||||
const offen = db.prepare(`
|
||||
SELECT s.message, s.scheduled_at, u.username
|
||||
FROM push_schedules s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime')
|
||||
ORDER BY s.scheduled_at ASC
|
||||
`).all();
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({
|
||||
erinnerungen: offen,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle
|
||||
// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert)
|
||||
function publishKanban() {
|
||||
if (!client?.connected) return;
|
||||
const karten = db.prepare(`
|
||||
SELECT k.title, k.priority, col.title AS spalte, u.username
|
||||
FROM kanban_cards k
|
||||
JOIN kanban_columns col ON col.id = k.column_id
|
||||
JOIN users u ON u.id = k.user_id
|
||||
ORDER BY u.username ASC, col.position ASC, k.position ASC
|
||||
`).all();
|
||||
const proNutzer = {};
|
||||
for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1;
|
||||
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({
|
||||
karten,
|
||||
pro_nutzer: proNutzer,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
|
||||
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
|
||||
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
|
||||
|
||||
module.exports = {
|
||||
connectMqtt,
|
||||
publishKoepiState,
|
||||
publishPresence,
|
||||
publishMediaAnfragen,
|
||||
publishDruckStatistik,
|
||||
publishPushErinnerungen,
|
||||
publishKanban,
|
||||
setKoepiCheckHandler,
|
||||
setMediaAckHandler,
|
||||
setMediaAckAllHandler,
|
||||
};
|
||||
85
backend/src/publicAccessLog.js
Normal file
85
backend/src/publicAccessLog.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const db = require('./db');
|
||||
|
||||
// Sehr einfache, kostenlose IP-Geolocation ohne API-Key (ip-api.com, ~45
|
||||
// Anfragen/Minute im Free-Tier — für dieses Nutzungsszenario völlig ausreichend).
|
||||
// Liefert bei privaten/lokalen IPs oder Fehlern bewusst leeren String zurück,
|
||||
// statt den eigentlichen Log-Eintrag zu blockieren.
|
||||
async function lookupLocation(ip) {
|
||||
if (!ip) return '';
|
||||
const clean = ip.replace('::ffff:', ''); // IPv4-mapped IPv6-Adressen bereinigen
|
||||
if (!clean || clean === '::1' || clean.startsWith('127.') || clean.startsWith('192.168.') || clean.startsWith('10.')) {
|
||||
return ''; // lokale/private Adresse, Geolocation ergibt keinen Sinn
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(clean)}?fields=status,country,city`);
|
||||
if (!res.ok) return '';
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success') return '';
|
||||
return [data.city, data.country].filter(Boolean).join(', ');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Sehr einfache User-Agent-Erkennung (kein externes Paket nötig) — liefert
|
||||
// z.B. "iPhone · Safari" oder "Windows · Firefox". Reihenfolge ist wichtig:
|
||||
// Edge/Samsung/Opera/Firefox enthalten selbst oft "Chrome" bzw. "Safari" im
|
||||
// User-Agent, müssen also VOR diesen geprüft werden.
|
||||
function parseDevice(ua) {
|
||||
if (!ua) return '';
|
||||
let os = '';
|
||||
if (/iPhone/i.test(ua)) os = 'iPhone';
|
||||
else if (/iPad/i.test(ua)) os = 'iPad';
|
||||
else if (/Android/i.test(ua)) os = 'Android';
|
||||
else if (/Windows/i.test(ua)) os = 'Windows';
|
||||
else if (/Macintosh|Mac OS X/i.test(ua)) os = 'Mac';
|
||||
else if (/Linux/i.test(ua)) os = 'Linux';
|
||||
|
||||
let browser = '';
|
||||
if (/EdgA|Edge|Edg\//i.test(ua)) browser = 'Edge';
|
||||
else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet';
|
||||
else if (/OPR\/|Opera/i.test(ua)) browser = 'Opera';
|
||||
else if (/Firefox/i.test(ua)) browser = 'Firefox';
|
||||
else if (/CriOS/i.test(ua)) browser = 'Chrome'; // Chrome auf iOS
|
||||
else if (/Chrome/i.test(ua)) browser = 'Chrome';
|
||||
else if (/Safari/i.test(ua)) browser = 'Safari';
|
||||
|
||||
return [os, browser].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// Wird von öffentlichen (loginfreien) Routen aufgerufen, sobald sie besucht
|
||||
// werden. linkType z.B. 'koepi_share'. Läuft bewusst asynchron/"fire and
|
||||
// forget" im Hintergrund — ein Fehler hier darf niemals die eigentliche
|
||||
// öffentliche Anfrage blockieren oder verlangsamen.
|
||||
//
|
||||
// Dedup: dieselbe IP, die denselben Link innerhalb der letzten 5 Minuten schon
|
||||
// besucht hat, wird nicht erneut geloggt (verhindert Log-Spam bei mehreren
|
||||
// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige
|
||||
// Geolocation-Abfragen).
|
||||
const DEDUP_WINDOW_MINUTES = 5;
|
||||
function logPublicAccess({ linkType, path, ip, userAgent, linkName }) {
|
||||
try {
|
||||
const recent = db.prepare(`
|
||||
SELECT 1 FROM public_access_log
|
||||
WHERE ip = ? AND path = ? AND created_at >= datetime('now','localtime',?)
|
||||
LIMIT 1
|
||||
`).get(ip || '', path || '', `-${DEDUP_WINDOW_MINUTES} minutes`);
|
||||
if (recent) return; // kürzlich schon geloggt, nichts weiter tun
|
||||
} catch (e) {
|
||||
console.error('Public-Access-Log Dedup-Fehler:', e.message);
|
||||
}
|
||||
|
||||
const device = parseDevice(userAgent);
|
||||
lookupLocation(ip).then(location => {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
||||
`).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || '');
|
||||
} catch (e) {
|
||||
console.error('Public-Access-Log Fehler:', e.message);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = { logPublicAccess };
|
||||
17
backend/src/pushLog.js
Normal file
17
backend/src/pushLog.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const db = require('./db');
|
||||
|
||||
// Wird von jeder Stelle im Code aufgerufen, die eine Pushover-Nachricht
|
||||
// verschickt — unabhängig vom Versand selbst, damit ein Fehler im Logging
|
||||
// nie den eigentlichen Push verhindert.
|
||||
function logPush({ userId = null, title = '', message = '', priority = 0, source = '', success = true }) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO push_log (user_id, title, message, priority, source, success, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
||||
`).run(userId, title, message, priority ?? 0, source, success ? 1 : 0);
|
||||
} catch (e) {
|
||||
console.error('Push-Log Fehler:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { logPush };
|
||||
604
backend/src/routes/admin.js
Normal file
604
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,604 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const multer = require('multer');
|
||||
const db = require('../db');
|
||||
const { logPush } = require('../pushLog');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const DB_PATH = process.env.DB_PATH || '/data/dickendock.db';
|
||||
const upload = multer({ dest: '/tmp/' });
|
||||
|
||||
// GET /api/admin/backup
|
||||
router.get('/backup', authenticate, requireAdmin, (req, res) => {
|
||||
if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'DB nicht gefunden' });
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
res.download(DB_PATH, `dickendock-backup-${date}.db`);
|
||||
});
|
||||
|
||||
// POST /api/admin/restore
|
||||
router.post('/restore', authenticate, requireAdmin, upload.single('database'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten' });
|
||||
try {
|
||||
if (fs.existsSync(DB_PATH)) fs.copyFileSync(DB_PATH, DB_PATH + '.bak');
|
||||
fs.copyFileSync(req.file.path, DB_PATH);
|
||||
fs.unlinkSync(req.file.path);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/users – alle Benutzer mit Statistiken
|
||||
router.get('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools,
|
||||
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
|
||||
FROM users u
|
||||
LEFT JOIN pushover_settings p ON p.user_id = u.id
|
||||
ORDER BY u.role DESC, u.username
|
||||
`).all();
|
||||
const stats = users.map(u => {
|
||||
const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c;
|
||||
const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c;
|
||||
const snippets_c = db.prepare('SELECT COUNT(*) c FROM snippets WHERE user_id=?').get(u.id).c;
|
||||
const todos = db.prepare('SELECT COUNT(*) c FROM todos WHERE user_id=?').get(u.id).c;
|
||||
const links = db.prepare('SELECT COUNT(*) c FROM quick_links WHERE user_id=?').get(u.id).c;
|
||||
const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c;
|
||||
const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length;
|
||||
const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c;
|
||||
let hidden_tools = [];
|
||||
try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {}
|
||||
return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
|
||||
});
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
// POST /api/admin/users – neuen Benutzer anlegen
|
||||
router.post('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
if (!username?.trim() || username.trim().length < 3)
|
||||
return res.status(400).json({ error: 'Benutzername mind. 3 Zeichen' });
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Passwort mind. 8 Zeichen' });
|
||||
if (!['user','admin'].includes(role))
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
const exists = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
|
||||
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
|
||||
const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)')
|
||||
.run(username.trim(), bcrypt.hashSync(password, 12), role);
|
||||
res.json({ id: r.lastInsertRowid, username: username.trim(), role });
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id – Benutzer löschen
|
||||
router.delete('/users/:id', authenticate, requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(id);
|
||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
// Sicherstellen dass mindestens ein Admin bleibt
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
|
||||
if (adminCount <= 1) return res.status(400).json({ error: 'Mindestens ein Administrator muss verbleiben' });
|
||||
}
|
||||
// Cascade: alle Daten des Users löschen
|
||||
db.prepare('DELETE FROM calculations WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM orders WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM todos WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM notes WHERE user_id=?').run(id);
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/reset-password – Passwort zurücksetzen
|
||||
router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) => {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Mind. 8 Zeichen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(newPassword, 12), req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Login-Sicherheit Admin-Endpoints ─────────────────────────────────────────
|
||||
|
||||
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
|
||||
|
||||
// GET gesperrte Konten mit IPs
|
||||
router.get('/lockouts', authenticate, requireAdmin, (req, res) => {
|
||||
const locked = db.prepare(`
|
||||
SELECT id, username, failed_attempts, locked_until, last_failed_at
|
||||
FROM users
|
||||
WHERE locked_until IS NOT NULL
|
||||
ORDER BY locked_until DESC
|
||||
`).all();
|
||||
|
||||
// IPs aus login_attempts hinzufügen
|
||||
const result = locked.map(u => {
|
||||
const ips = db.prepare(`
|
||||
SELECT DISTINCT ip FROM login_attempts
|
||||
WHERE username=? AND success=0
|
||||
ORDER BY created_at DESC LIMIT 10
|
||||
`).all(u.username).map(r => r.ip).filter(Boolean);
|
||||
return { ...u, ips };
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// DELETE Sperre aufheben + Login-Versuche löschen
|
||||
router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.params.userId);
|
||||
if (user) {
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(user.username);
|
||||
}
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?')
|
||||
.run(req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET Login-Einstellungen
|
||||
router.get('/security-settings', authenticate, requireAdmin, (req, res) => {
|
||||
res.json({
|
||||
login_max_attempts: getSetting('login_max_attempts') || '5',
|
||||
login_lockout_minutes: getSetting('login_lockout_minutes') || '30',
|
||||
});
|
||||
});
|
||||
|
||||
// PUT Login-Einstellungen
|
||||
router.put('/security-settings', authenticate, requireAdmin, (req, res) => {
|
||||
const { login_max_attempts, login_lockout_minutes } = req.body;
|
||||
for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) {
|
||||
if (v !== undefined)
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0));
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET Login-Verlauf (letzte 50)
|
||||
router.get('/login-log', authenticate, requireAdmin, (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50
|
||||
`).all());
|
||||
});
|
||||
|
||||
// Toggle hidden
|
||||
router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const newHidden = user.hidden ? 0 : 1;
|
||||
db.prepare('UPDATE users SET hidden=? WHERE id=?').run(newHidden, user.id);
|
||||
res.json({ hidden: newHidden });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen
|
||||
// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js)
|
||||
router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { hidden_tools } = req.body;
|
||||
if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' });
|
||||
const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))];
|
||||
db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id);
|
||||
res.json({ hidden_tools: cleaned });
|
||||
});
|
||||
|
||||
// Test-Pushover an beliebigen User senden
|
||||
router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' });
|
||||
const { message = 'Wat is mit meinen Fische?' } = req.body;
|
||||
const title = '📣 DockStation';
|
||||
try {
|
||||
const r = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title, message, priority: 0 }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 1) {
|
||||
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test' });
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
|
||||
res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' });
|
||||
}
|
||||
} catch(e) {
|
||||
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
|
||||
router.get('/webdav', authenticate, requireAdmin, (req, res) => {
|
||||
const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || '';
|
||||
res.json({
|
||||
url: get('webdav_url'),
|
||||
user: get('webdav_user'),
|
||||
password: get('webdav_password'),
|
||||
basePath: get('webdav_base_path') || '/dickendock',
|
||||
enabled: get('webdav_enabled') === '1',
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/webdav', authenticate, requireAdmin, (req, res) => {
|
||||
const { url, user, password, basePath, enabled } = req.body;
|
||||
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || '');
|
||||
set('webdav_url', url);
|
||||
set('webdav_user', user);
|
||||
set('webdav_password', password);
|
||||
set('webdav_base_path', basePath || '/dickendock');
|
||||
set('webdav_enabled', enabled ? '1' : '0');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => {
|
||||
// Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert)
|
||||
const { url, user, password, basePath } = req.body;
|
||||
try {
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const base = new URL(url);
|
||||
const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/');
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const req2 = mod.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: testPath,
|
||||
method: 'PROPFIND',
|
||||
headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' },
|
||||
rejectUnauthorized: false,
|
||||
timeout: 8000,
|
||||
}, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); });
|
||||
req2.on('error', reject);
|
||||
req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); });
|
||||
req2.write('<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>');
|
||||
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;
|
||||
179
backend/src/routes/auth.js
Normal file
179
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
|
||||
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function logAttempt(username, ip, success) {
|
||||
db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))")
|
||||
.run(username, ip || 'unknown', success ? 1 : 0);
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function checkAndLock(user) {
|
||||
const maxAttempts = parseInt(getSetting('login_max_attempts') || '5');
|
||||
const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) return { locked: true, until };
|
||||
// Sperre abgelaufen → zurücksetzen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
}
|
||||
|
||||
// Fehlversuch zählen
|
||||
const newAttempts = (user.failed_attempts || 0) + 1;
|
||||
if (newAttempts >= maxAttempts) {
|
||||
db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newAttempts, user.id);
|
||||
const until = new Date(Date.now() + lockoutMinutes * 60 * 1000);
|
||||
return { locked: true, until, newLock: true };
|
||||
}
|
||||
db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?")
|
||||
.run(newAttempts, user.id);
|
||||
return { locked: false, remaining: maxAttempts - newAttempts };
|
||||
}
|
||||
|
||||
// ── POST /api/auth/login ──────────────────────────────────────────────────────
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
|
||||
// Unbekannter User → trotzdem loggen (kein Timing-Angriff)
|
||||
if (!user) {
|
||||
logAttempt(username, ip, false);
|
||||
return res.status(401).json({ error: 'Benutzername oder Passwort falsch' });
|
||||
}
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) {
|
||||
logAttempt(username, ip, false);
|
||||
const min = Math.ceil((until - Date.now()) / 60000);
|
||||
return res.status(403).json({
|
||||
error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`,
|
||||
locked: true,
|
||||
lockedUntil: until.toISOString(),
|
||||
});
|
||||
}
|
||||
// Abgelaufen → zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
}
|
||||
|
||||
// Passwort prüfen
|
||||
const valid = bcrypt.compareSync(password, user.password_hash);
|
||||
logAttempt(username, ip, valid);
|
||||
|
||||
if (!valid) {
|
||||
const result = checkAndLock(user);
|
||||
if (result.locked) {
|
||||
const min = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
return res.status(403).json({
|
||||
error: result.newLock
|
||||
? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.`
|
||||
: `Konto gesperrt. Bitte später versuchen.`,
|
||||
locked: true,
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Erfolgreich → Zähler zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
SECRET, { expiresIn: '7d' }
|
||||
);
|
||||
res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// ── POST /api/auth/change-password ───────────────────────────────────────────
|
||||
router.post('/change-password', authenticate, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash))
|
||||
return res.status(400).json({ error: 'Aktuelles Passwort falsch' });
|
||||
if (!newPassword || newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Mindestens 8 Zeichen erforderlich' });
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
||||
.run(bcrypt.hashSync(newPassword, 12), req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── POST /api/auth/change-username ───────────────────────────────────────────
|
||||
router.post('/change-username', authenticate, (req, res) => {
|
||||
const { newUsername, password } = req.body;
|
||||
if (!newUsername || newUsername.trim().length < 3)
|
||||
return res.status(400).json({ error: 'Mind. 3 Zeichen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id);
|
||||
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
|
||||
db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id);
|
||||
const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' });
|
||||
res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } });
|
||||
});
|
||||
|
||||
// ── DELETE /api/auth/account ──────────────────────────────────────────────────
|
||||
router.delete('/account', authenticate, (req, res) => {
|
||||
const { password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!password || !bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c;
|
||||
if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Admin kann nicht gelöscht werden' });
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── GET/PUT /api/auth/preferences ─────────────────────────────────────────────
|
||||
router.get('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.preferences || '{}')); }
|
||||
catch { res.json({}); }
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
let current = {};
|
||||
try { current = JSON.parse(user?.preferences || '{}'); } catch {}
|
||||
const merged = { ...current, ...req.body };
|
||||
db.prepare('UPDATE users SET preferences=? WHERE id=?').run(JSON.stringify(merged), req.user.id);
|
||||
res.json(merged);
|
||||
});
|
||||
|
||||
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
|
||||
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
|
||||
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
|
||||
// darf nur der Admin ändern.
|
||||
router.get('/hidden-tools', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
|
||||
catch { res.json([]); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
69
backend/src/routes/calendar.js
Normal file
69
backend/src/routes/calendar.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// Fetch mit Redirect-Unterstützung
|
||||
function fetchWithRedirects(url, hops = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (hops > 5) return reject(new Error('Zu viele Redirects'));
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { return reject(new Error('Ungültige URL')); }
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar, text/plain, */*' }
|
||||
}, res => {
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location
|
||||
: new URL(res.headers.location, url).href;
|
||||
res.destroy();
|
||||
return fetchWithRedirects(next, hops + 1).then(resolve).catch(reject);
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', d => { data += d; if (data.length > 3*1024*1024) { res.destroy(); reject(new Error('Feed zu groß')); } });
|
||||
res.on('end', () => resolve(data));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
// GET /fetch?url=...
|
||||
router.get('/fetch', authenticate, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL fehlt' });
|
||||
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
|
||||
try {
|
||||
const data = await fetchWithRedirects(url);
|
||||
if (!data.includes('BEGIN:VCALENDAR'))
|
||||
return res.status(502).json({ error: 'Kein gültiger iCal-Feed' });
|
||||
res.type('text/calendar; charset=utf-8').send(data);
|
||||
} catch(e) { res.status(502).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /feeds
|
||||
router.get('/feeds', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE user_id=? ORDER BY id').all(req.user.id));
|
||||
});
|
||||
|
||||
// POST /feeds
|
||||
router.post('/feeds', authenticate, (req, res) => {
|
||||
const { name, url, color='#4ecdc4' } = req.body;
|
||||
if (!name?.trim() || !url?.trim()) return res.status(400).json({ error: 'Name und URL erforderlich' });
|
||||
const r = db.prepare('INSERT INTO calendar_feeds (user_id,name,url,color) VALUES (?,?,?,?)').run(req.user.id, name.trim(), url.trim(), color);
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// DELETE /feeds/:id
|
||||
router.delete('/feeds/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM calendar_feeds WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
330
backend/src/routes/dashboard.js
Normal file
330
backend/src/routes/dashboard.js
Normal file
@@ -0,0 +1,330 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Quick Links ───────────────────────────────────────────────────────────────
|
||||
router.get('/links', authenticate, (req, res) => {
|
||||
const id = uid(req);
|
||||
// Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1
|
||||
const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id);
|
||||
const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
res.json([...legacy, ...listLinks, ...folders]);
|
||||
});
|
||||
router.post('/links', authenticate, (req, res) => {
|
||||
const { title, url, icon = '🔗' } = req.body;
|
||||
if (!title || !url) return res.status(400).json({ error: 'Titel und URL erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM quick_links WHERE user_id=?').get(uid(req));
|
||||
const r = db.prepare('INSERT INTO quick_links (user_id,title,url,icon,sort_order) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), title, url, icon, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
router.put('/links/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM quick_links WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title=ex.title, url=ex.url, icon=ex.icon } = req.body;
|
||||
db.prepare('UPDATE quick_links SET title=?,url=?,icon=? WHERE id=? AND user_id=?')
|
||||
.run(title, url, icon, req.params.id, uid(req));
|
||||
res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(req.params.id));
|
||||
});
|
||||
router.delete('/links/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM quick_links WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/links-order', authenticate, (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids erforderlich' });
|
||||
const update = db.prepare('UPDATE quick_links SET sort_order=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => { ids.forEach((id, i) => update.run(i, id, uid(req))); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Todos ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/todos', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE user_id=? ORDER BY done,sort_order,id').all(uid(req)));
|
||||
});
|
||||
router.post('/todos', authenticate, (req, res) => {
|
||||
const { text } = req.body;
|
||||
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM todos WHERE user_id=?').get(uid(req));
|
||||
const r = db.prepare('INSERT INTO todos (user_id,text,sort_order) VALUES (?,?,?)')
|
||||
.run(uid(req), text.trim(), (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
router.put('/todos/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM todos WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const text = req.body.text ?? ex.text;
|
||||
const done = req.body.done !== undefined ? (req.body.done ? 1 : 0) : ex.done;
|
||||
db.prepare('UPDATE todos SET text=?,done=? WHERE id=? AND user_id=?').run(text, done, req.params.id, uid(req));
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(req.params.id));
|
||||
});
|
||||
router.delete('/todos/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM todos WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Notizen ───────────────────────────────────────────────────────────────────
|
||||
router.get('/note', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM notes WHERE user_id=?').get(uid(req)) || { content: '', updated_at: null });
|
||||
});
|
||||
router.put('/note', authenticate, (req, res) => {
|
||||
db.prepare(`INSERT INTO notes (user_id,content,updated_at) VALUES (?,?,CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET content=excluded.content,updated_at=CURRENT_TIMESTAMP`)
|
||||
.run(uid(req), req.body.content ?? '');
|
||||
res.json({ success: true, updated_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
|
||||
// ── Statistiken ───────────────────────────────────────────────────────────────
|
||||
router.get('/stats', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
|
||||
const orders = db.prepare(`
|
||||
SELECT status, bezahlt,
|
||||
SUM(COALESCE(custom_price_sum_view,0)) AS revenue
|
||||
FROM (
|
||||
SELECT o.status, o.bezahlt,
|
||||
SUM(COALESCE(oi.custom_price,0)*COALESCE(oi.stueckzahl,0)) AS custom_price_sum_view
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id=o.id
|
||||
WHERE o.user_id=?
|
||||
GROUP BY o.id
|
||||
)
|
||||
GROUP BY status, bezahlt
|
||||
`).all(uid);
|
||||
|
||||
const totalOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(uid).c;
|
||||
const bezahltOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=1 AND (abgeholt=0 OR abgeholt IS NULL)').get(uid).c;
|
||||
// Revenue: Gesamt-Festpreis hat Priorität, sonst Summe der Positions-Festpreise
|
||||
const paidOrders = db.prepare('SELECT * FROM orders WHERE user_id=? AND bezahlt=1').all(uid);
|
||||
let totalRevenue = 0;
|
||||
for (const o of paidOrders) {
|
||||
if (o.custom_price != null) {
|
||||
totalRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
totalRevenue += sum;
|
||||
}
|
||||
}
|
||||
|
||||
const openOrders = db.prepare("SELECT * FROM orders WHERE user_id=? AND bezahlt=0 AND status!='warteliste'").all(uid);
|
||||
let offeneRevenue = 0;
|
||||
for (const o of openOrders) {
|
||||
if (o.custom_price != null) {
|
||||
offeneRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
offeneRevenue += sum;
|
||||
}
|
||||
}
|
||||
const thisMonth = new Date();
|
||||
thisMonth.setDate(1); thisMonth.setHours(0,0,0,0);
|
||||
const ordersThisMonth = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND created_at>=?').get(uid, thisMonth.toISOString()).c;
|
||||
const totalCalcs = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(uid).c;
|
||||
|
||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0 };
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=0 GROUP BY status').all(uid)) {
|
||||
byStatus[r.status] = r.c;
|
||||
}
|
||||
|
||||
// Grundkosten berechnen (preis_freundschaft = Selbstkosten pro Stück aus Kalkulation)
|
||||
function getBaseCost(orderId) {
|
||||
const items = db.prepare(`
|
||||
SELECT oi.stueckzahl, oi.custom_price,
|
||||
COALESCE(c.preis_freundschaft, oi.preis_freundschaft) as basis
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).all(orderId);
|
||||
return items.reduce((sum, i) => sum + (i.basis * i.stueckzahl), 0);
|
||||
}
|
||||
|
||||
function getRevenue(order) {
|
||||
if (order.custom_price != null) return parseFloat(order.custom_price);
|
||||
return db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(order.id).s;
|
||||
}
|
||||
|
||||
let totalProfit = 0;
|
||||
for (const o of paidOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
totalProfit += rev - cost;
|
||||
}
|
||||
|
||||
let offeneProfit = 0;
|
||||
for (const o of openOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
offeneProfit += rev - cost;
|
||||
}
|
||||
|
||||
res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus, totalProfit, offeneProfit });
|
||||
});
|
||||
|
||||
// ── Ideen-Board ───────────────────────────────────────────────────────────────
|
||||
router.get('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const items = db.prepare(`
|
||||
SELECT b.*, us.username as author
|
||||
FROM board_items b JOIN users us ON us.id=b.user_id
|
||||
ORDER BY b.type ASC, b.created_at DESC
|
||||
`).all();
|
||||
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
|
||||
res.json({ items, unread });
|
||||
});
|
||||
|
||||
router.get('/board/unread', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
const unreadPromoted = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at > ?").get(lastRead).c
|
||||
: db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at IS NOT NULL").get().c;
|
||||
res.json({ unread, unreadPromoted });
|
||||
});
|
||||
|
||||
router.post('/board/read', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const { type, title, description = '' } = req.body;
|
||||
if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
|
||||
if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' });
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(type, u, title.trim(), description.trim());
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid),
|
||||
author: req.user.username });
|
||||
});
|
||||
|
||||
router.put('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const { title, description } = req.body;
|
||||
db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?')
|
||||
.run(title ?? item.title, description ?? item.description, item.id);
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username });
|
||||
});
|
||||
|
||||
router.delete('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM board_items WHERE id=?').run(item.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Wunsch zur Roadmap befördern (Admin only)
|
||||
router.post('/board/:id/promote', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' });
|
||||
db.prepare("UPDATE board_items SET type='roadmap', promoted_from_wish=1, promoted_at=datetime('now','localtime') WHERE id=?").run(item.id);
|
||||
const updated = db.prepare(`
|
||||
SELECT b.*, u.username as author FROM board_items b
|
||||
JOIN users u ON u.id=b.user_id WHERE b.id=?
|
||||
`).get(item.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ── Changelog ─────────────────────────────────────────────────────────────────
|
||||
router.get('/changelog', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM changelog ORDER BY created_at DESC').all());
|
||||
});
|
||||
|
||||
router.get('/changelog/unread', authenticate, (req, res) => {
|
||||
const u = req.user.id;
|
||||
const lastRead = db.prepare('SELECT last_read FROM changelog_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const count = lastRead
|
||||
? db.prepare('SELECT COUNT(*) c FROM changelog WHERE created_at > ?').get(lastRead).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM changelog').get().c;
|
||||
res.json({ unread: count });
|
||||
});
|
||||
|
||||
router.post('/changelog/read', authenticate, (req, res) => {
|
||||
db.prepare(`INSERT OR REPLACE INTO changelog_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`)
|
||||
.run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/changelog', authenticate, requireAdmin, (req, res) => {
|
||||
const { version, title, body, build_time } = req.body;
|
||||
if (!version?.trim() || !title?.trim()) return res.status(400).json({ error: 'Version und Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO changelog (version, title, body, build_time, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(version.trim(), title.trim(), body?.trim() || '', build_time || null);
|
||||
res.json(db.prepare('SELECT * FROM changelog WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/changelog/:id', authenticate, requireAdmin, (req, res) => {
|
||||
db.prepare('DELETE FROM changelog WHERE id=?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||
router.get('/push-schedules', authenticate, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM push_schedules
|
||||
WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime')
|
||||
ORDER BY scheduled_at ASC
|
||||
`).all(uid(req));
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/push-schedules', authenticate, (req, res) => {
|
||||
const { message, scheduled_at } = req.body;
|
||||
if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' });
|
||||
// Normalisieren: T→Leerzeichen, Sekunden ergänzen
|
||||
const normalized = scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '');
|
||||
const r = db.prepare(`
|
||||
INSERT INTO push_schedules (user_id, message, scheduled_at, created_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
`).run(uid(req), message.trim(), normalized);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/push-schedules/:id', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { message, scheduled_at } = req.body;
|
||||
const normalized = scheduled_at ? scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '') : row.scheduled_at;
|
||||
db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?')
|
||||
.run(message ?? row.message, normalized, row.id);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id));
|
||||
});
|
||||
|
||||
router.delete('/push-schedules/:id', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Unquittierte Favoriten für Admin-Dashboard-Badge
|
||||
router.get('/media-favorites/unacked', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.json({ count: 0 });
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get();
|
||||
res.json({ count: count.n });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
260
backend/src/routes/search.js
Normal file
260
backend/src/routes/search.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const safe = fn => { try { return fn(); } catch { return []; } };
|
||||
const safeGet = fn => { try { return fn(); } catch { return null; } };
|
||||
|
||||
// ── iCal Fetch + Parse ────────────────────────────────────────────────────────
|
||||
function fetchIcal(url) {
|
||||
return new Promise(resolve => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar,*/*' }
|
||||
}, res => {
|
||||
// Redirects verfolgen
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
res.destroy();
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location : new URL(res.headers.location, url).href;
|
||||
fetchIcal(next).then(resolve);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', d => { data += d; if (data.length > 1e6) { res.destroy(); resolve(null); } });
|
||||
res.on('end', () => resolve(data.includes('BEGIN:VCALENDAR') ? data : null));
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
function parseIcal(raw) {
|
||||
const events = [];
|
||||
const parts = raw.split('BEGIN:VEVENT');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const b = parts[i];
|
||||
const get = key => {
|
||||
// Handle folded lines and key variants like DTSTART;TZID=...
|
||||
const m = b.match(new RegExp(key + '[^:]*:([^\\r\\n]+)'));
|
||||
return m ? m[1].replace(/\\n/g,' ').replace(/\\,/g,',').replace(/\\;/g,';').trim() : '';
|
||||
};
|
||||
const summary = get('SUMMARY');
|
||||
if (!summary) continue;
|
||||
events.push({ summary, description:get('DESCRIPTION'), location:get('LOCATION'), start:get('DTSTART'), end:get('DTEND') });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Kalender-Sync Endpoint (POST /api/search/sync-calendar) ──────────────────
|
||||
// Frontend ruft das beim Dashboard-Laden auf — kein Sync beim Suchen selbst
|
||||
router.post('/sync-calendar', authenticate, async (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const force = req.body?.force === true;
|
||||
|
||||
const feeds = safeGet(() => db.prepare('SELECT * FROM calendar_feeds WHERE user_id=?').all(uid)) || [];
|
||||
if (!feeds.length) return res.json({ ok: true, synced: 0, message: 'Keine Kalender-Abos vorhanden' });
|
||||
|
||||
// Alter des Caches prüfen
|
||||
// Alte Cache-Prüfung: Wenn noch historische Events drin (start_dt < 2000), force-sync
|
||||
const hasOldData = safeGet(() =>
|
||||
db.prepare("SELECT 1 FROM calendar_event_cache WHERE user_id=? AND start_dt < '20200101' LIMIT 1").get(uid)
|
||||
);
|
||||
if (!force && !hasOldData) {
|
||||
const lastSync = safeGet(() =>
|
||||
db.prepare("SELECT MAX(synced_at) as s FROM calendar_event_cache WHERE user_id=?").get(uid)?.s
|
||||
);
|
||||
if (lastSync && (Date.now() - new Date(lastSync).getTime()) < 10 * 60 * 1000) {
|
||||
return res.json({ ok: true, synced: 0, message: 'Cache noch frisch' });
|
||||
}
|
||||
}
|
||||
|
||||
let totalSynced = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const raw = await fetchIcal(feed.url);
|
||||
if (!raw) { errors.push(`${feed.name}: kein gültiger iCal-Feed`); continue; }
|
||||
const events = parseIcal(raw);
|
||||
if (!events.length) { errors.push(`${feed.name}: 0 Events geparst`); continue; }
|
||||
|
||||
// Nur zukünftige Events speichern (ab gestern rückwärts 1 Tag für Tagesevents)
|
||||
// start_dt Format: YYYYMMDD oder YYYYMMDDTHHmmssZ → string-vergleichbar
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const minDt = yesterday.toISOString().replace(/-/g,'').slice(0,8); // '20260604'
|
||||
const futureEvents = events.filter(ev => !ev.start || ev.start >= minDt);
|
||||
|
||||
// prepare INSIDE try-catch – wirft wenn Tabelle nicht existiert
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO calendar_event_cache
|
||||
(feed_id, user_id, summary, description, location, start_dt, end_dt, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM calendar_event_cache WHERE feed_id=?').run(feed.id);
|
||||
futureEvents.slice(0, 500).forEach(ev =>
|
||||
ins.run(feed.id, uid, ev.summary||'', ev.description||'', ev.location||'', ev.start||'', ev.end||'')
|
||||
);
|
||||
})();
|
||||
totalSynced += futureEvents.length;
|
||||
} catch(e) {
|
||||
console.error(`[CalendarSync] Feed "${feed.name}" (${feed.url}): ${e.message}`);
|
||||
errors.push(`${feed.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CalendarSync] uid=${uid} synced=${totalSynced} feeds=${feeds.length} errors=${errors.length}`);
|
||||
res.json({ ok: true, synced: totalSynced, feeds: feeds.length, errors });
|
||||
});
|
||||
|
||||
// ── Suche ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
const uid = req.user.id;
|
||||
|
||||
if (q.length < 2) return res.json({
|
||||
links:[], folders:[], snippets:[], calculations:[], orders:[],
|
||||
todos:[], notes:[], board_items:[], changelog:[],
|
||||
files:[], file_folders:[], push_schedules:[],
|
||||
calendar_feeds:[], calendar_events:[], qr_codes:[]
|
||||
});
|
||||
|
||||
const like = `%${q.toLowerCase()}%`;
|
||||
|
||||
const links = safe(() => db.prepare(`
|
||||
SELECT id, title, url, icon, description FROM link_list
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 8
|
||||
`).all(uid, like, like, like));
|
||||
|
||||
const folders = safe(() => db.prepare(`
|
||||
SELECT id, name, icon FROM link_list_folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const snippets = safe(() => db.prepare(`
|
||||
SELECT id, title, description, language FROM snippets
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calculations = safe(() => db.prepare(`
|
||||
SELECT id, name FROM calculations
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const orders = safe(() => db.prepare(`
|
||||
SELECT id, name, status FROM orders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const todos = safe(() => db.prepare(`
|
||||
SELECT id, text, done FROM todos
|
||||
WHERE user_id=? AND LOWER(text) LIKE ?
|
||||
ORDER BY done, created_at DESC LIMIT 6
|
||||
`).all(uid, like));
|
||||
|
||||
const noteRow = safeGet(() => db.prepare(
|
||||
'SELECT content FROM notes WHERE user_id=? AND LOWER(content) LIKE ?'
|
||||
).get(uid, like));
|
||||
const notes = noteRow ? [(() => {
|
||||
const idx = noteRow.content.toLowerCase().indexOf(q.toLowerCase());
|
||||
const s = Math.max(0, idx - 30);
|
||||
return { id:'note', preview: (s>0?'…':'') + noteRow.content.slice(s, idx+60) + '…' };
|
||||
})()] : [];
|
||||
|
||||
const board_items = safe(() => db.prepare(`
|
||||
SELECT id, title, description, type FROM board_items
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
const changelog = safe(() => db.prepare(`
|
||||
SELECT id, version, title, body FROM changelog
|
||||
WHERE LOWER(title) LIKE ? OR LOWER(body) LIKE ?
|
||||
ORDER BY created_at DESC LIMIT 4
|
||||
`).all(like, like));
|
||||
|
||||
const files = safe(() => db.prepare(`
|
||||
SELECT id, originalname, mimetype, size FROM files
|
||||
WHERE user_id=? AND (LOWER(originalname) LIKE ? OR LOWER(filename) LIKE ?)
|
||||
ORDER BY originalname LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const file_folders = safe(() => db.prepare(`
|
||||
SELECT id, name FROM folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const push_schedules = safe(() => db.prepare(`
|
||||
SELECT id, message, scheduled_at FROM push_schedules
|
||||
WHERE user_id=? AND LOWER(message) LIKE ? AND sent=0
|
||||
ORDER BY scheduled_at LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const calendar_feeds = safe(() => db.prepare(`
|
||||
SELECT id, name, url FROM calendar_feeds
|
||||
WHERE user_id=? AND (LOWER(name) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY name LIMIT 4
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calendar_events = safe(() => db.prepare(`
|
||||
SELECT c.id, c.summary, c.description, c.location, c.start_dt, f.name as feed_name
|
||||
FROM calendar_event_cache c
|
||||
JOIN calendar_feeds f ON f.id=c.feed_id
|
||||
WHERE c.user_id=? AND f.user_id=?
|
||||
AND (LOWER(c.summary) LIKE ? OR LOWER(c.description) LIKE ? OR LOWER(c.location) LIKE ?)
|
||||
AND c.start_dt >= strftime('%Y%m%d', 'now')
|
||||
ORDER BY c.start_dt LIMIT 8
|
||||
`).all(uid, uid, like, like, like));
|
||||
|
||||
const qr_codes = safe(() => db.prepare(`
|
||||
SELECT id, label, url FROM qr_codes
|
||||
WHERE user_id=? AND (LOWER(label) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
// Gebietseroberung-Spiele
|
||||
const geo_games = safe(() => db.prepare(`
|
||||
SELECT g.id, u1.username as owner_name, u2.username as opp_name, g.status, g.current_turn
|
||||
FROM geo_games g
|
||||
JOIN users u1 ON u1.id=g.owner_id
|
||||
JOIN users u2 ON u2.id=g.opponent_id
|
||||
WHERE (g.owner_id=? OR g.opponent_id=?)
|
||||
AND (LOWER(u1.username) LIKE ? OR LOWER(u2.username) LIKE ?)
|
||||
ORDER BY g.updated_at DESC LIMIT 5
|
||||
`).all(uid, uid, like, like));
|
||||
|
||||
// Andere Benutzer – für Chat-Navigation (nur Benutzernamen, keine sensiblen Daten)
|
||||
const users = safe(() => db.prepare(`
|
||||
SELECT id, username FROM users
|
||||
WHERE id != ? AND LOWER(username) LIKE ?
|
||||
ORDER BY username LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
res.json({ links, folders, snippets, calculations, orders, todos, notes,
|
||||
board_items, changelog, files, file_folders, push_schedules,
|
||||
calendar_feeds, calendar_events, qr_codes, users, geo_games });
|
||||
});
|
||||
|
||||
// Debug-Endpoint: Kalender-Cache inspizieren
|
||||
router.get('/calendar-debug', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=?').get(uid);
|
||||
const samples = db.prepare('SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? ORDER BY start_dt LIMIT 10').all(uid);
|
||||
const today = db.prepare("SELECT strftime('%Y%m%d','now') as d").get();
|
||||
const future = db.prepare("SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=? AND start_dt >= strftime('%Y%m%d','now')").get(uid);
|
||||
const search = db.prepare("SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? AND LOWER(summary) LIKE '%arzt%'").all(uid);
|
||||
res.json({ count: count.n, today: today.d, future_count: future.n, samples, arzt_matches: search });
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
40
backend/src/routes/system.js
Normal file
40
backend/src/routes/system.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
let VERSION = 'v1.0.0';
|
||||
try { VERSION = fs.readFileSync(path.join(__dirname, '../../version.txt'), 'utf8').trim(); } catch {}
|
||||
|
||||
const GITEA_URL = process.env.GITEA_URL;
|
||||
const GITEA_REPO = process.env.GITEA_REPO;
|
||||
|
||||
router.get('/version', authenticate, (_req, res) => res.json({ version: VERSION }));
|
||||
|
||||
router.get('/update-check', authenticate, async (_req, res) => {
|
||||
if (!GITEA_URL || !GITEA_REPO)
|
||||
return res.json({ hasUpdate: false, currentVersion: VERSION, configured: false });
|
||||
try {
|
||||
const r = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=5`,
|
||||
{ signal: AbortSignal.timeout(5000) });
|
||||
const releases = await r.json();
|
||||
if (!Array.isArray(releases) || !releases.length)
|
||||
return res.json({ hasUpdate: false, currentVersion: VERSION, configured: true });
|
||||
res.json({
|
||||
hasUpdate: releases[0].tag_name !== VERSION,
|
||||
currentVersion: VERSION,
|
||||
latestVersion: releases[0].tag_name,
|
||||
configured: true,
|
||||
releases: releases.map(r => ({
|
||||
version: r.tag_name, name: r.name || r.tag_name,
|
||||
body: r.body || '(Keine Beschreibung)', publishedAt: r.published_at,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
res.json({ hasUpdate: false, currentVersion: VERSION, configured: true });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
169
backend/src/tools/bestellungen/routes.js
Normal file
169
backend/src/tools/bestellungen/routes.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// Status der Bestellung automatisch aus den Stück-Zählern berechnen
|
||||
const autoUpdateOrderStatus = (orderId) => {
|
||||
const items = db.prepare('SELECT * FROM order_items WHERE order_id=?').all(orderId);
|
||||
if (!items.length) return;
|
||||
const totalPieces = items.reduce((s,i) => s + (i.stueckzahl||0), 0);
|
||||
const fertigPieces = items.reduce((s,i) => s + (i.qty_fertig||0), 0);
|
||||
const arbeitPieces = items.reduce((s,i) => s + (i.qty_in_arbeit||0), 0);
|
||||
let newStatus = 'warteliste';
|
||||
if (fertigPieces >= totalPieces && totalPieces > 0) newStatus = 'fertig';
|
||||
else if (fertigPieces > 0 || arbeitPieces > 0) newStatus = 'in_arbeit';
|
||||
db.prepare('UPDATE orders SET status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?').run(newStatus, orderId);
|
||||
};
|
||||
|
||||
const loadOrder = (id, userId) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(id, userId);
|
||||
if (!order) return null;
|
||||
order.items = db.prepare('SELECT * FROM order_items WHERE order_id=? ORDER BY id').all(id);
|
||||
return order;
|
||||
};
|
||||
|
||||
// GET / – Liste mit Zählern + Items
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const orders = db.prepare(`
|
||||
SELECT o.*,
|
||||
COUNT(oi.id) AS item_count,
|
||||
SUM(COALESCE(oi.qty_fertig,0)) AS qty_fertig_total,
|
||||
SUM(COALESCE(oi.stueckzahl,0)) AS qty_total,
|
||||
SUM(COALESCE(oi.custom_price,0) * COALESCE(oi.stueckzahl,0)) AS custom_price_sum,
|
||||
SUM(COALESCE(oi.stunden,0) * COALESCE(oi.stueckzahl,0)) AS stunden_total
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.user_id = ?
|
||||
GROUP BY o.id
|
||||
ORDER BY o.created_at DESC
|
||||
`).all(uid(req));
|
||||
// Attach items to each order
|
||||
const items = db.prepare('SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE user_id=?) ORDER BY order_id, id').all(uid(req));
|
||||
const itemsByOrder = {};
|
||||
for (const item of items) {
|
||||
if (!itemsByOrder[item.order_id]) itemsByOrder[item.order_id] = [];
|
||||
itemsByOrder[item.order_id].push(item);
|
||||
}
|
||||
for (const order of orders) order.items = itemsByOrder[order.id] || [];
|
||||
res.json(orders);
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const order = loadOrder(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(order);
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { name, bemerkung='', status='warteliste', custom_price=null } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const r = db.prepare('INSERT INTO orders (user_id,name,bemerkung,status,custom_price) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), name.trim(), bemerkung, status, custom_price);
|
||||
res.json(loadOrder(r.lastInsertRowid, uid(req)));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { name=ex.name, bemerkung=ex.bemerkung, status=ex.status, custom_price=ex.custom_price } = req.body;
|
||||
|
||||
// Bezahlt-Status mit Datum (optional überschreibbar)
|
||||
let bezahlt = ex.bezahlt;
|
||||
let bezahlt_am = ex.bezahlt_am;
|
||||
if (req.body.bezahlt !== undefined) {
|
||||
bezahlt = req.body.bezahlt ? 1 : 0;
|
||||
bezahlt_am = req.body.bezahlt
|
||||
? (req.body.bezahlt_am_override || new Date().toISOString())
|
||||
: null;
|
||||
}
|
||||
|
||||
// Abgeholt-Status mit Datum (optional überschreibbar)
|
||||
let abgeholt = ex.abgeholt;
|
||||
let abgeholt_am = ex.abgeholt_am;
|
||||
if (req.body.abgeholt !== undefined) {
|
||||
abgeholt = req.body.abgeholt ? 1 : 0;
|
||||
abgeholt_am = req.body.abgeholt
|
||||
? (req.body.abgeholt_am_override || new Date().toISOString())
|
||||
: null;
|
||||
}
|
||||
|
||||
db.prepare('UPDATE orders SET name=?,bemerkung=?,status=?,custom_price=?,bezahlt=?,bezahlt_am=?,abgeholt=?,abgeholt_am=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?')
|
||||
.run(name, bemerkung, status, custom_price, bezahlt, bezahlt_am, abgeholt, abgeholt_am, req.params.id, uid(req));
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM orders WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/:id/items', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { calculation_id, stueckzahl=1, custom_price=null } = req.body;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(calculation_id, uid(req));
|
||||
if (!calc) return res.status(404).json({ error: 'Archiv-Eintrag nicht gefunden' });
|
||||
const existing = db.prepare('SELECT * FROM order_items WHERE order_id=? AND calculation_id=?').get(req.params.id, calculation_id);
|
||||
if (existing) {
|
||||
const newQty = existing.stueckzahl + stueckzahl;
|
||||
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=qty_warteliste+? WHERE id=?').run(newQty, stueckzahl, existing.id);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO order_items
|
||||
(order_id,calculation_id,calc_name,preis_freundschaft,preis_normal,preis_auftrag,
|
||||
stueckzahl,custom_price,status,qty_warteliste,qty_in_arbeit,qty_fertig,stunden)
|
||||
VALUES (?,?,?,?,?,?,?,?,'warteliste',?,0,0,?)`)
|
||||
.run(req.params.id, calc.id, calc.name, calc.preis_freundschaft, calc.preis_normal, calc.preis_auftrag,
|
||||
stueckzahl, custom_price, stueckzahl, calc.stunden || 0);
|
||||
}
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.put('/:id/items/:itemId', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const item = db.prepare('SELECT * FROM order_items WHERE id=? AND order_id=?').get(req.params.itemId, req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Position nicht gefunden' });
|
||||
|
||||
// Stückzahl geändert → Warteliste anpassen
|
||||
if (req.body.stueckzahl !== undefined) {
|
||||
const newQty = Math.max(1, req.body.stueckzahl);
|
||||
const diff = newQty - item.stueckzahl;
|
||||
const newWarte = Math.max(0, item.qty_warteliste + diff);
|
||||
db.prepare('UPDATE order_items SET stueckzahl=?,qty_warteliste=? WHERE id=?').run(newQty, newWarte, item.id);
|
||||
}
|
||||
|
||||
// Status-Zähler direkt setzen
|
||||
if (req.body.qty_warteliste !== undefined || req.body.qty_in_arbeit !== undefined || req.body.qty_fertig !== undefined) {
|
||||
const fresh = db.prepare('SELECT * FROM order_items WHERE id=?').get(item.id);
|
||||
const w = req.body.qty_warteliste ?? fresh.qty_warteliste;
|
||||
const a = req.body.qty_in_arbeit ?? fresh.qty_in_arbeit;
|
||||
const f = req.body.qty_fertig ?? fresh.qty_fertig;
|
||||
// Summe darf stueckzahl nicht überschreiten
|
||||
const total = w + a + f;
|
||||
if (total <= fresh.stueckzahl) {
|
||||
db.prepare('UPDATE order_items SET qty_warteliste=?,qty_in_arbeit=?,qty_fertig=? WHERE id=?').run(w, a, f, item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Festpreis
|
||||
if (req.body.custom_price !== undefined) {
|
||||
db.prepare('UPDATE order_items SET custom_price=? WHERE id=?').run(req.body.custom_price, item.id);
|
||||
}
|
||||
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
router.delete('/:id/items/:itemId', authenticate, (req, res) => {
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!order) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM order_items WHERE id=? AND order_id=?').run(req.params.itemId, req.params.id);
|
||||
autoUpdateOrderStatus(req.params.id);
|
||||
res.json(loadOrder(req.params.id, uid(req)));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
103
backend/src/tools/dateien/public-share-public.js
Normal file
103
backend/src/tools/dateien/public-share-public.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Öffentliche Endpunkte – kein Login nötig
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { logPublicAccess } = require('../../publicAccessLog');
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
function getShare(token) {
|
||||
return db.prepare(`
|
||||
SELECT s.*,
|
||||
f.originalname as file_name, f.size as file_size, f.mimetype as mime_type, f.filename as file_path,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.token = ?
|
||||
`).get(token);
|
||||
}
|
||||
|
||||
function isExpired(s) {
|
||||
return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
// GET /:token – Metadaten
|
||||
router.get('/:token', (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
logPublicAccess({ linkType: 'file_share', path: `/s/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] });
|
||||
res.json({
|
||||
label: s.label,
|
||||
file_name: s.file_name,
|
||||
file_size: s.file_size,
|
||||
mime_type: s.mime_type,
|
||||
folder_name: s.folder_name,
|
||||
is_folder: !!s.folder_id,
|
||||
needs_password: !!s.password_hash,
|
||||
expires_at: s.expires_at,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /:token/verify – nur Passwort prüfen, kein Download
|
||||
router.post('/:token/verify', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
res.json({ ok: true, file_name: s.file_name, file_size: s.file_size, mime_type: s.mime_type, is_folder: !!s.folder_id });
|
||||
});
|
||||
|
||||
// POST /:token/download – Datei herunterladen
|
||||
router.post('/:token/download', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const filePath = path.join(UPLOAD_DIR, s.file_path);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`);
|
||||
res.setHeader('Content-Type', s.mime_type || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// POST /:token/folder – Ordnerinhalt
|
||||
router.post('/:token/folder', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const files = db.prepare(`
|
||||
SELECT id, originalname as name, size, mimetype as mime_type, filename as file_path FROM files
|
||||
WHERE folder_id=? AND user_id=? ORDER BY name
|
||||
`).all(s.folder_id, s.user_id);
|
||||
db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id);
|
||||
res.json({ folder_name: s.folder_name, files, token: s.token });
|
||||
});
|
||||
|
||||
// POST /:token/folder/:fileId – Einzelne Datei aus Ordner
|
||||
router.post('/:token/folder/:fileId', async (req, res) => {
|
||||
const s = getShare(req.params.token);
|
||||
if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
||||
const ok = await bcrypt.compare(req.body?.password || '', s.password_hash);
|
||||
if (!ok) return res.status(403).json({ error: 'Falsches Passwort' });
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?')
|
||||
.get(req.params.fileId, s.folder_id, s.user_id);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
const filePath = path.join(UPLOAD_DIR, file.file_path || file.filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name || file.originalname)}"`);
|
||||
res.setHeader('Content-Type', file.mime_type || file.mimetype || 'application/octet-stream');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
backend/src/tools/dateien/public-share.js
Normal file
88
backend/src/tools/dateien/public-share.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
function generateToken() { return crypto.randomBytes(24).toString('base64url'); }
|
||||
|
||||
// GET / – eigene Shares auflisten
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
try {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.id, s.token, s.user_id, s.file_id, s.folder_id, s.label,
|
||||
s.password_hash, s.expires_at, s.created_at, s.download_count,
|
||||
f.originalname as file_name, f.size as file_size,
|
||||
fo.name as folder_name
|
||||
FROM public_file_shares s
|
||||
LEFT JOIN files f ON f.id = s.file_id
|
||||
LEFT JOIN folders fo ON fo.id = s.folder_id
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares });
|
||||
} catch(e) {
|
||||
console.error('[public-shares GET]', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST / – neuen Share erstellen
|
||||
router.post('/', authenticate, async (req, res) => {
|
||||
const { file_id, folder_id, password, expires_hours, label } = req.body;
|
||||
if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' });
|
||||
if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' });
|
||||
if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' });
|
||||
|
||||
if (file_id) {
|
||||
const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id);
|
||||
if (!f) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
if (folder_id) {
|
||||
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id);
|
||||
if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const password_hash = await bcrypt.hash(password.trim(), 10);
|
||||
const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at);
|
||||
|
||||
const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid);
|
||||
res.json({ share, token });
|
||||
});
|
||||
|
||||
// DELETE /:id – Share löschen
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT id FROM public_file_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM public_file_shares WHERE id=?').run(s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Debug: Tabellenstatus + eigene Shares mit Token
|
||||
router.get('/debug-table', (req, res) => {
|
||||
try {
|
||||
const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get();
|
||||
const count = exists ? db.prepare('SELECT COUNT(*) as n FROM public_file_shares').get() : null;
|
||||
const cols = exists ? db.pragma('table_info(public_file_shares)').map(c=>c.name) : [];
|
||||
const sample = exists ? db.prepare('SELECT id, token, user_id, file_id, folder_id, expires_at, download_count FROM public_file_shares LIMIT 5').all() : [];
|
||||
// Test: ersten Token direkt abfragen wie public endpoint es tut
|
||||
let publicTest = null;
|
||||
if (sample.length > 0) {
|
||||
const t = sample[0].token;
|
||||
const s = db.prepare('SELECT s.*, f.originalname as file_name, fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id WHERE s.token=?').get(t);
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
publicTest = { token: t, expires_at: s?.expires_at, now, isExpired: s?.expires_at < now, typeof_expires: typeof s?.expires_at };
|
||||
}
|
||||
res.json({ exists: !!exists, count: count?.n, cols, sample, publicTest });
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
444
backend/src/tools/dateien/routes.js
Normal file
444
backend/src/tools/dateien/routes.js
Normal file
@@ -0,0 +1,444 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const webdav = require('./webdav');
|
||||
|
||||
// ── WebDAV Hilfsfunktionen ────────────────────────────────────────────────
|
||||
function getFolderPathById(folderId) {
|
||||
if (!folderId) return '';
|
||||
const parts = [];
|
||||
let cur = folderId;
|
||||
const seen = new Set();
|
||||
while (cur) {
|
||||
if (seen.has(cur)) break; seen.add(cur);
|
||||
const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur);
|
||||
if (!fo) break;
|
||||
parts.unshift(fo.name.replace(/[\/]/g, '_'));
|
||||
cur = fo.parent_id;
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function davFileUrl(username, folderId, filename) {
|
||||
const cfg = webdav.getConfig();
|
||||
if (!cfg.enabled) return null;
|
||||
const base = webdav.userPath(cfg, username);
|
||||
const folder = getFolderPathById(folderId);
|
||||
return folder ? `${base}/${folder}/${filename}` : `${base}/${filename}`;
|
||||
}
|
||||
|
||||
function davFolderUrl(username, folderId) {
|
||||
const cfg = webdav.getConfig();
|
||||
if (!cfg.enabled) return null;
|
||||
const base = webdav.userPath(cfg, username);
|
||||
const folder = getFolderPathById(folderId);
|
||||
return folder ? `${base}/${folder}` : base;
|
||||
}
|
||||
|
||||
function getUserName(userId) {
|
||||
return db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || String(userId);
|
||||
}
|
||||
|
||||
const getSetting = key => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value;
|
||||
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const memStorage = multer.memoryStorage();
|
||||
|
||||
const storage = diskStorage; // Standard: Disk
|
||||
const getUpload = () => multer({
|
||||
storage,
|
||||
limits: { fileSize: parseInt(getSetting('file_max_size_mb') || '50') * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = (getSetting('file_allowed_ext') || '').split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
|
||||
if (!allowed.length) return cb(null, true);
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
allowed.includes(ext) ? cb(null, true) : cb(new Error(`Dateityp nicht erlaubt: ${ext}`));
|
||||
},
|
||||
});
|
||||
|
||||
// Walk folder tree upward to check if any ancestor is shared with user
|
||||
function isInSharedFolder(folderId, uid) {
|
||||
let cur = folderId;
|
||||
while (cur) {
|
||||
if (db.prepare('SELECT id FROM folder_shares WHERE folder_id=? AND shared_with=?').get(cur, uid)) return true;
|
||||
const p = db.prepare('SELECT parent_id FROM folders WHERE id=?').get(cur);
|
||||
cur = p?.parent_id || null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const enrichFolder = (folder) => ({
|
||||
...folder,
|
||||
fileCount: db.prepare('SELECT COUNT(*) c FROM files WHERE folder_id=?').get(folder.id).c,
|
||||
subCount: db.prepare('SELECT COUNT(*) c FROM folders WHERE parent_id=?').get(folder.id).c,
|
||||
totalSize: db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE folder_id=?').get(folder.id).s,
|
||||
});
|
||||
|
||||
// ── Ordner ────────────────────────────────────────────────────────────────────
|
||||
router.get('/folders', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const parentId = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
||||
|
||||
// Own folders in this directory
|
||||
const own = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id=? ORDER BY name').all(uid, parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND parent_id IS NULL ORDER BY name').all(uid);
|
||||
|
||||
// Shared folders
|
||||
let shared = [];
|
||||
if (!parentId) {
|
||||
shared = db.prepare(`
|
||||
SELECT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(fs.created_at) as shared_at
|
||||
FROM folders f JOIN users u ON u.id=f.user_id
|
||||
JOIN folder_shares fs ON fs.folder_id=f.id
|
||||
JOIN users su ON su.id=fs.shared_with
|
||||
WHERE fs.shared_with=?
|
||||
GROUP BY f.id ORDER BY f.name
|
||||
`).all(uid);
|
||||
} else if (isInSharedFolder(parentId, uid)) {
|
||||
const parentFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(parentId);
|
||||
if (parentFolder) {
|
||||
shared = db.prepare('SELECT f.*, u.username as owner FROM folders f JOIN users u ON u.id=f.user_id WHERE f.parent_id=? AND f.user_id!=? ORDER BY f.name').all(parentId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Folders shared BY me (root only)
|
||||
const sharedByMe = parentId ? [] : db.prepare(`
|
||||
SELECT DISTINCT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(fs.created_at) as shared_at
|
||||
FROM folders f JOIN users u ON u.id=f.user_id
|
||||
JOIN folder_shares fs ON fs.folder_id=f.id
|
||||
JOIN users su ON su.id=fs.shared_with
|
||||
WHERE f.user_id=?
|
||||
GROUP BY f.id ORDER BY f.name
|
||||
`).all(uid);
|
||||
|
||||
res.json({ own: own.map(enrichFolder), shared: shared.map(enrichFolder), sharedByMe: sharedByMe.map(enrichFolder) });
|
||||
});
|
||||
|
||||
router.post('/folders', authenticate, (req, res) => {
|
||||
const { name, parent_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
if (parentId) {
|
||||
const parent = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(parentId, req.user.id);
|
||||
if (!parent) return res.status(404).json({ error: 'Überordner nicht gefunden' });
|
||||
}
|
||||
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(req.user.id, name.trim(), parentId);
|
||||
const newFolder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||
// WebDAV: Ordner anlegen
|
||||
const _davFolderCreate = davFolderUrl(getUserName(req.user.id), newFolder.id);
|
||||
if (_davFolderCreate) webdav.mkdirp(_davFolderCreate).catch(e => console.warn('[webdav] mkdir:', e.message));
|
||||
res.json(enrichFolder(newFolder));
|
||||
});
|
||||
|
||||
router.delete('/folders/:id', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, uid);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
// Alle Unterordner rekursiv sammeln
|
||||
function collectSubfolderIds(parentId) {
|
||||
const children = db.prepare('SELECT id FROM folders WHERE parent_id=? AND user_id=?').all(parentId, uid);
|
||||
let ids = [parentId];
|
||||
for (const c of children) ids = ids.concat(collectSubfolderIds(c.id));
|
||||
return ids;
|
||||
}
|
||||
const allFolderIds = collectSubfolderIds(folder.id);
|
||||
|
||||
// WebDAV: Ordner löschen (vor DB-Löschung damit Pfad noch auflösbar)
|
||||
const _davFolderDel = davFolderUrl(getUserName(uid), folder.id);
|
||||
if (_davFolderDel) webdav.deleteFile(_davFolderDel).catch(e => console.warn('[webdav] rmdir:', e.message));
|
||||
|
||||
// Alle Dateien in allen Unterordnern löschen (Disk + DB)
|
||||
for (const fid of allFolderIds) {
|
||||
const files = db.prepare('SELECT * FROM files WHERE folder_id=? AND user_id=?').all(fid, uid);
|
||||
for (const f of files) { try { fs.unlinkSync(path.join(UPLOAD_DIR, f.filename)); } catch {} }
|
||||
db.prepare('DELETE FROM files WHERE folder_id=? AND user_id=?').run(fid, uid);
|
||||
}
|
||||
// Alle Unterordner + Hauptordner aus DB löschen
|
||||
for (const fid of allFolderIds.reverse()) {
|
||||
db.prepare('DELETE FROM folders WHERE id=?').run(fid);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/folders/:id/shares', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare('SELECT u.id,u.username,fs.created_at as shared_at FROM folder_shares fs JOIN users u ON u.id=fs.shared_with WHERE fs.folder_id=?').all(folder.id));
|
||||
});
|
||||
|
||||
router.post('/folders/:id/share', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare('INSERT OR IGNORE INTO folder_shares (folder_id,shared_by,shared_with) VALUES (?,?,?)').run(folder.id, req.user.id, target.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM folder_shares WHERE folder_id=? AND shared_with=?').run(folder.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Dateien ───────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const folderId = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
||||
|
||||
// Own files in this directory
|
||||
const own = folderId
|
||||
? db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id=? ORDER BY f.created_at DESC').all(uid, folderId)
|
||||
: db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.user_id=? AND f.folder_id IS NULL ORDER BY f.created_at DESC').all(uid);
|
||||
|
||||
// Files shared BY me
|
||||
const sharedByMe = folderId ? [] : db.prepare(`
|
||||
SELECT DISTINCT f.*, u.username as owner,
|
||||
GROUP_CONCAT(su.username) as shared_with_names,
|
||||
MIN(s.created_at) as shared_at,
|
||||
MAX(s.accessed_at) as accessed_at,
|
||||
MAX(CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END) as has_password
|
||||
FROM files f JOIN users u ON u.id=f.user_id
|
||||
JOIN file_shares s ON s.file_id=f.id
|
||||
JOIN users su ON su.id=s.shared_with
|
||||
WHERE f.user_id=?
|
||||
GROUP BY f.id ORDER BY f.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
// Files shared with me
|
||||
let shared = [];
|
||||
if (!folderId) {
|
||||
shared = db.prepare(`
|
||||
SELECT f.*, u.username as owner, sb.username as shared_by_name,
|
||||
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM files f JOIN users u ON u.id=f.user_id
|
||||
JOIN file_shares s ON s.file_id=f.id
|
||||
JOIN users sb ON sb.id=s.shared_by
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY f.created_at DESC
|
||||
`).all(uid);
|
||||
} else if (isInSharedFolder(folderId, uid)) {
|
||||
const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
||||
if (folder) {
|
||||
shared = db.prepare('SELECT f.*, u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.folder_id=? AND f.user_id=? ORDER BY f.created_at DESC').all(folderId, folder.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ own, shared, sharedByMe });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
|
||||
const totalLimitMb= parseInt(getSetting('file_total_size_mb') || '500');
|
||||
const usedBytes = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
|
||||
if (usedBytes >= totalLimitMb * 1024 * 1024)
|
||||
return res.status(400).json({ error: `Gesamtlimit von ${totalLimitMb} MB erreicht` });
|
||||
getUpload().single('file')(req, res, err => {
|
||||
if (err) {
|
||||
if (req.file) try { fs.unlinkSync(req.file.path); } catch {}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei' });
|
||||
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
||||
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
|
||||
.run(uid, req.file.filename, req.file.originalname, req.file.mimetype||'application/octet-stream', req.file.size, folderId);
|
||||
const newFile = db.prepare('SELECT f.*,u.username as owner FROM files f JOIN users u ON u.id=f.user_id WHERE f.id=?').get(r.lastInsertRowid);
|
||||
|
||||
// WebDAV: Datei asynchron spiegeln
|
||||
const _davPath = davFileUrl(getUserName(uid), folderId, req.file.originalname);
|
||||
if (_davPath) {
|
||||
fs.readFile(path.join(UPLOAD_DIR, req.file.filename), (err, buf) => {
|
||||
if (!err) webdav.uploadFile(buf, _davPath, req.file.mimetype).catch(e => console.error('[webdav upload]', e.message));
|
||||
});
|
||||
}
|
||||
|
||||
res.json(newFile);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3D-Modelle Hilfsendpoints ─────────────────────────────────────────────────
|
||||
|
||||
// Sucht einen Ordner ohne ihn anzulegen – gibt null zurück wenn nicht gefunden
|
||||
router.get('/find-folder', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, parent_id } = req.query;
|
||||
if (!name?.trim()) return res.json(null);
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
const folder = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
|
||||
res.json(folder ? enrichFolder(folder) : null);
|
||||
});
|
||||
|
||||
// Stellt sicher dass ein Ordner existiert (Name + optionaler parent), gibt ihn zurück
|
||||
router.post('/ensure-folder', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, parent_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const parentId = parent_id ? parseInt(parent_id) : null;
|
||||
|
||||
// Suche existierenden Ordner
|
||||
const existing = parentId
|
||||
? db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id=?').get(uid, name.trim(), parentId)
|
||||
: db.prepare('SELECT * FROM folders WHERE user_id=? AND name=? AND parent_id IS NULL').get(uid, name.trim());
|
||||
|
||||
if (existing) return res.json(enrichFolder(existing));
|
||||
|
||||
const r = db.prepare('INSERT INTO folders (user_id,name,parent_id) VALUES (?,?,?)').run(uid, name.trim(), parentId);
|
||||
res.json(enrichFolder(db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid)));
|
||||
});
|
||||
|
||||
// Upload 3D-Dateien (ohne Ext-Beschränkung, für den Kalkulator)
|
||||
const upload3d = multer({
|
||||
storage,
|
||||
limits: { fileSize: parseInt(process.env.MAX_3D_SIZE_MB || '200') * 1024 * 1024 },
|
||||
});
|
||||
|
||||
router.post('/upload-3d', authenticate, (req, res) => {
|
||||
upload3d.array('files', 20)(req, res, err => {
|
||||
if (err) {
|
||||
if (req.files?.length) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (!req.files?.length) return res.status(400).json({ error: 'Keine Dateien' });
|
||||
const uid = req.user.id;
|
||||
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
||||
const inserted = [];
|
||||
for (const f of req.files) {
|
||||
const r = db.prepare('INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id) VALUES (?,?,?,?,?,?)')
|
||||
.run(uid, f.filename, f.originalname, f.mimetype || 'application/octet-stream', f.size, folderId);
|
||||
inserted.push(r.lastInsertRowid);
|
||||
}
|
||||
res.json({ uploaded: inserted.length });
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/storage', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const used = db.prepare('SELECT COALESCE(SUM(size),0) AS s FROM files WHERE user_id=?').get(uid).s;
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM files WHERE user_id=?').get(uid).c;
|
||||
const maxSizeMb = parseInt(getSetting('file_max_size_mb') || '50');
|
||||
const totalLimitMb = parseInt(getSetting('file_total_size_mb') || '500');
|
||||
res.json({ used, count, maxSizeMb, totalLimitMb });
|
||||
});
|
||||
|
||||
router.get('/:id/download', authenticate, async (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const isOwner = file.user_id === uid;
|
||||
const share = db.prepare('SELECT * FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, uid);
|
||||
const inSharedDir = file.folder_id && isInSharedFolder(file.folder_id, uid);
|
||||
|
||||
if (!isOwner && !share && !inSharedDir) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
// Passwortprüfung für Shares (Owner braucht kein Passwort)
|
||||
if (!isOwner && share?.password_hash) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pw = req.query.password || req.headers['x-share-password'] || '';
|
||||
const ok = pw && await bcrypt.compare(pw, share.password_hash);
|
||||
if (!ok) return res.status(403).json({
|
||||
error: 'Falsches Passwort',
|
||||
passwordRequired: !pw // true = noch kein PW eingegeben, false = falsches PW
|
||||
});
|
||||
}
|
||||
|
||||
const fp = path.join(UPLOAD_DIR, file.filename);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
|
||||
|
||||
// Zugriff tracken (immer aktualisieren damit es sicher gesetzt wird)
|
||||
if (share) {
|
||||
db.prepare("UPDATE file_shares SET accessed_at = datetime('now','localtime') WHERE file_id=? AND shared_with=?")
|
||||
.run(file.id, uid);
|
||||
}
|
||||
|
||||
res.download(fp, file.originalname);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
// WebDAV: Datei löschen
|
||||
const _davFileDel = davFileUrl(getUserName(file.user_id), file.folder_id, file.originalname);
|
||||
if (_davFileDel) webdav.deleteFile(_davFileDel).catch(e => console.warn('[webdav] delete:', e.message));
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, file.filename)); } catch {}
|
||||
db.prepare('DELETE FROM files WHERE id=?').run(file.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at,
|
||||
s.accessed_at,
|
||||
CASE WHEN s.password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
|
||||
FROM file_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.file_id=?
|
||||
`).all(file.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, async (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
let passwordHash = null;
|
||||
if (req.body.password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
passwordHash = await bcrypt.hash(req.body.password, 10);
|
||||
}
|
||||
const existing = db.prepare('SELECT id FROM file_shares WHERE file_id=? AND shared_with=?').get(file.id, target.id);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE file_shares SET password_hash=?, accessed_at=NULL WHERE file_id=? AND shared_with=?')
|
||||
.run(passwordHash, file.id, target.id);
|
||||
} else {
|
||||
db.prepare('INSERT INTO file_shares (file_id,shared_by,shared_with,password_hash) VALUES (?,?,?,?)')
|
||||
.run(file.id, req.user.id, target.id, passwordHash);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!file) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM file_shares WHERE file_id=? AND shared_with=?').run(file.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Settings
|
||||
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM admin_settings').all();
|
||||
const obj = {}; for (const r of s) obj[r.key] = r.value;
|
||||
res.json(obj);
|
||||
});
|
||||
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
||||
for (const key of ['file_max_size_mb','file_total_size_mb','file_allowed_ext']) {
|
||||
if (req.body[key] !== undefined)
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(key, String(req.body[key]));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
173
backend/src/tools/dateien/upload-share.js
Normal file
173
backend/src/tools/dateien/upload-share.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../../db');
|
||||
const { logPublicAccess } = require('../../publicAccessLog');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
function generateToken() { return crypto.randomBytes(20).toString('base64url'); }
|
||||
|
||||
function getOrCreateUploadFolder(userId) {
|
||||
let f = db.prepare("SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1").get(userId);
|
||||
if (!f) {
|
||||
const r = db.prepare("INSERT INTO folders (user_id,name,is_upload_folder,created_at) VALUES (?,?,1,datetime('now','localtime'))").run(userId,'Upload');
|
||||
f = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function canUseUploadShare(userId) {
|
||||
const u = db.prepare('SELECT role,allow_upload_share FROM users WHERE id=?').get(userId);
|
||||
return u && (u.role==='admin' || !!u.allow_upload_share);
|
||||
}
|
||||
|
||||
// ── Eigene Shares ─────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
WHERE s.user_id=?
|
||||
GROUP BY s.id ORDER BY s.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json({ shares, folder });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const { password, expires_hours=24, max_size_mb=10 } = req.body;
|
||||
if (!password?.trim()) return res.status(400).json({ error:'Passwort erforderlich' });
|
||||
const folder = getOrCreateUploadFolder(req.user.id);
|
||||
const token = generateToken();
|
||||
const hash = bcrypt.hashSync(password.trim(), 10);
|
||||
const expires = new Date(Date.now() + Number(expires_hours)*3600000).toISOString();
|
||||
const r = db.prepare(`
|
||||
INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
|
||||
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
||||
db.prepare("UPDATE upload_shares SET is_active=0, deactivated_reason='manual' WHERE id=?").run(s.id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
router.delete('/:id/remove', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM upload_shares WHERE id=?').run(s.id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
router.get('/logs', authenticate, (req, res) => {
|
||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
WHERE l.user_id=? ORDER BY l.uploaded_at DESC LIMIT 100
|
||||
`).all(req.user.id);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.expires_at, s.max_size_mb, u.username
|
||||
FROM upload_share_logs l
|
||||
JOIN upload_shares s ON s.id=l.share_id
|
||||
JOIN users u ON u.id=l.user_id
|
||||
ORDER BY l.uploaded_at DESC LIMIT 500
|
||||
`).all();
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
|
||||
const shares = db.prepare(`
|
||||
SELECT s.*, u.username, COUNT(l.id) as upload_count
|
||||
FROM upload_shares s JOIN users u ON u.id=s.user_id
|
||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||
GROUP BY s.id ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
res.json(shares);
|
||||
});
|
||||
|
||||
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
|
||||
const { user_id, allow } = req.body;
|
||||
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
|
||||
res.json({ ok:true });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Share-Info ────────────────────────────────────────────────────
|
||||
router.get('/public/:token', (req, res) => {
|
||||
const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
logPublicAccess({ linkType: 'upload_share', path: `/u/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] });
|
||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Passwort prüfen ───────────────────────────────────────────────
|
||||
router.post('/public/:token/verify', (req, res) => {
|
||||
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
|
||||
if (!bcrypt.compareSync(req.body?.password || '', s.password_hash)) {
|
||||
const attempts = (s.failed_attempts||0) + 1;
|
||||
if (attempts >= 3) {
|
||||
// Link sperren
|
||||
db.prepare("UPDATE upload_shares SET is_active=0, failed_attempts=?, deactivated_reason='too_many_attempts' WHERE id=?").run(attempts, s.id);
|
||||
return res.status(401).json({ error:'Zu viele Fehlversuche – Link wurde gesperrt', locked:true });
|
||||
}
|
||||
db.prepare("UPDATE upload_shares SET failed_attempts=? WHERE id=?").run(attempts, s.id);
|
||||
return res.status(401).json({ error:'Falsches Passwort', attempts_left: 3-attempts });
|
||||
}
|
||||
|
||||
// Erfolgreich – Fehlversuche zurücksetzen
|
||||
db.prepare("UPDATE upload_shares SET failed_attempts=0 WHERE id=?").run(s.id);
|
||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||
});
|
||||
|
||||
// ── Öffentlich: Upload ────────────────────────────────────────────────────────
|
||||
router.post('/public/:token/upload', (req, res) => {
|
||||
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||
|
||||
const pw = req.headers['x-upload-password'] || '';
|
||||
if (!bcrypt.compareSync(pw, s.password_hash)) return res.status(401).json({ error:'Nicht autorisiert' });
|
||||
|
||||
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
|
||||
const upload = multer({ storage, limits:{ fileSize: s.max_size_mb*1024*1024 } }).single('file');
|
||||
|
||||
upload(req, res, err => {
|
||||
if (err) {
|
||||
if (err.code==='LIMIT_FILE_SIZE') return res.status(413).json({ error:`Datei zu groß (max ${s.max_size_mb} MB)` });
|
||||
return res.status(500).json({ error:err.message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ error:'Keine Datei' });
|
||||
try {
|
||||
db.prepare(`INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id,created_at) VALUES (?,?,?,?,?,?,datetime('now','localtime'))`).run(s.user_id,req.file.filename,req.file.originalname,req.file.mimetype,req.file.size,s.folder_id);
|
||||
db.prepare(`INSERT INTO upload_share_logs (share_id,user_id,originalname,size,ip_address,uploaded_at) VALUES (?,?,?,?,?,datetime('now','localtime'))`).run(s.id,s.user_id,req.file.originalname,req.file.size,req.ip||'');
|
||||
res.json({ ok:true, filename:req.file.originalname });
|
||||
} catch(e) {
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
|
||||
res.status(500).json({ error:e.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
169
backend/src/tools/dateien/webdav.js
Normal file
169
backend/src/tools/dateien/webdav.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* WebDAV Storage Backend für Synology NAS
|
||||
* Abstrahiert alle Datei-Operationen – lokal oder WebDAV
|
||||
*/
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../../db');
|
||||
|
||||
function getSetting(key) {
|
||||
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(key)?.value || '';
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
url: getSetting('webdav_url'), // z.B. http://192.168.1.100:5005
|
||||
user: getSetting('webdav_user'),
|
||||
password: getSetting('webdav_password'),
|
||||
basePath: getSetting('webdav_base_path') || '/dickendock', // z.B. /dickendock
|
||||
enabled: getSetting('webdav_enabled') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
function authHeader(cfg) {
|
||||
return 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64');
|
||||
}
|
||||
|
||||
// WebDAV Request ausführen
|
||||
function davRequest(method, davPath, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cfg = getConfig();
|
||||
const base = new URL(cfg.url);
|
||||
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
|
||||
const headers = {
|
||||
'Authorization': authHeader(cfg),
|
||||
'Accept': '*/*',
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
const reqOpts = {
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: fullPath,
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false, // Synology Self-Signed Certs
|
||||
};
|
||||
|
||||
if (opts.body) {
|
||||
headers['Content-Length'] = Buffer.byteLength(opts.body);
|
||||
}
|
||||
|
||||
const req = mod.request(reqOpts, res => {
|
||||
let data = '';
|
||||
res.setEncoding('binary');
|
||||
res.on('data', d => data += d);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Öffentliche API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordner auf WebDAV anlegen (inkl. alle Parent-Ordner)
|
||||
*/
|
||||
async function mkdirp(davPath) {
|
||||
const parts = davPath.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current += '/' + part;
|
||||
const r = await davRequest('MKCOL', current);
|
||||
// 201 = erstellt, 301/405/409/423 = schon vorhanden oder Konflikt (ignorieren)
|
||||
const ok = [201, 301, 405, 409, 423].includes(r.status);
|
||||
if (!ok) {
|
||||
console.warn(`[webdav mkdirp] MKCOL ${current} → ${r.status} (ignoriert)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Basispfad auf WebDAV: /dickendock/{username}
|
||||
*/
|
||||
function userPath(cfg, username) {
|
||||
return cfg.basePath.replace(/\/$/, '') + '/' + username.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei auf WebDAV hochladen
|
||||
* @param {Buffer} buffer - Dateiinhalt
|
||||
* @param {string} davPath - Zielpfad auf WebDAV
|
||||
* @param {string} mimeType
|
||||
*/
|
||||
async function uploadFile(buffer, davPath, mimeType = 'application/octet-stream') {
|
||||
try { await mkdirp(davPath.split('/').slice(0, -1).join('/')); } catch(e) {
|
||||
console.warn('[webdav] mkdirp warning:', e.message);
|
||||
}
|
||||
const r = await new Promise((resolve, reject) => {
|
||||
const cfg = getConfig();
|
||||
const base = new URL(cfg.url);
|
||||
const fullPath = base.pathname.replace(/\/$/, '') + davPath;
|
||||
const mod = base.protocol === 'https:' ? https : http;
|
||||
|
||||
const headers = {
|
||||
'Authorization': authHeader(cfg),
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': buffer.length,
|
||||
};
|
||||
|
||||
const req = mod.request({
|
||||
hostname: base.hostname,
|
||||
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
||||
path: fullPath,
|
||||
method: 'PUT',
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
}, res => { res.resume(); resolve({ status: res.statusCode }); });
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(buffer);
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (r.status !== 201 && r.status !== 204) {
|
||||
throw new Error(`PUT ${davPath} → ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei von WebDAV downloaden (gibt Buffer zurück)
|
||||
*/
|
||||
async function downloadFile(davPath) {
|
||||
const r = await davRequest('GET', davPath);
|
||||
if (r.status !== 200) throw new Error(`GET ${davPath} → ${r.status}`);
|
||||
return Buffer.from(r.body, 'binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei/Ordner auf WebDAV löschen
|
||||
*/
|
||||
async function deleteFile(davPath) {
|
||||
const r = await davRequest('DELETE', davPath);
|
||||
if (r.status !== 204 && r.status !== 200 && r.status !== 404) {
|
||||
throw new Error(`DELETE ${davPath} → ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung testen
|
||||
*/
|
||||
async function testConnection() {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.url || !cfg.user || !cfg.password) throw new Error('WebDAV nicht konfiguriert');
|
||||
const r = await davRequest('PROPFIND', cfg.basePath || '/', {
|
||||
headers: { 'Depth': '0', 'Content-Type': 'application/xml' },
|
||||
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>',
|
||||
});
|
||||
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 };
|
||||
617
backend/src/tools/gebietseroberung/routes.js
Normal file
617
backend/src/tools/gebietseroberung/routes.js
Normal file
@@ -0,0 +1,617 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { logPush } = require('../../pushLog');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
const cols = db.pragma('table_info(geo_games)').map(c => c.name);
|
||||
if (!cols.length) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS geo_games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
opponent_id INTEGER NOT NULL,
|
||||
grid TEXT NOT NULL,
|
||||
terrain TEXT NOT NULL DEFAULT '[]',
|
||||
current_turn INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
winner_id INTEGER,
|
||||
move_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_event TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
for (const [col, def] of [
|
||||
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['terrain', 'TEXT'],
|
||||
['last_event', 'TEXT'],
|
||||
['owner_head', 'TEXT'],
|
||||
['opp_head', 'TEXT'],
|
||||
['owner_scouts', 'TEXT'],
|
||||
['opp_scouts', 'TEXT'],
|
||||
['seen_by', 'TEXT'],
|
||||
]) {
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
|
||||
}
|
||||
// fog_owner/fog_opp waren alte Spalten, ignorieren
|
||||
})();
|
||||
|
||||
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||
const GRID = 20;
|
||||
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
|
||||
const T_NORMAL = 0;
|
||||
const T_GOLD = 1;
|
||||
const T_MINE = 2;
|
||||
const T_ROCK = 3;
|
||||
|
||||
// ── Terrain generieren ────────────────────────────────────────────────────────
|
||||
function generateTerrain(ownerHead, oppHead) {
|
||||
const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL));
|
||||
const safe = new Set();
|
||||
// 3x3 Sicherheitszone um beide Startecken
|
||||
for (const headStr of [ownerHead, oppHead]) {
|
||||
const [hr,hc] = headStr.split(',').map(Number);
|
||||
for (let dr=-2;dr<=2;dr++) for (let dc=-2;dc<=2;dc++) {
|
||||
const r=hr+dr, c=hc+dc;
|
||||
if (r>=0&&r<GRID&&c>=0&&c<GRID) safe.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cells = [];
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
|
||||
for (let i=cells.length-1;i>0;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<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
|
||||
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
|
||||
for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Fog of War ────────────────────────────────────────────────────────────────
|
||||
function computeFog(grid, playerId, scouts = []) {
|
||||
const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false));
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] === playerId) {
|
||||
visible[r][c] = true;
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const nr=r+dr, nc=c+dc;
|
||||
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scout-Aufdeckungen: 3x3 um jeden Scout-Mittelpunkt
|
||||
for (const [sr,sc] of scouts) {
|
||||
for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) {
|
||||
const nr=sr+dr, nc=sc+dc;
|
||||
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function createGrid(ownerId, oppId) {
|
||||
const corners = [[0,0],[0,GRID-1],[GRID-1,0],[GRID-1,GRID-1]];
|
||||
for (let i=corners.length-1;i>0;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<GRID&&c>=0&&c<GRID&&grid[r][c]===playerId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function canMove(grid, terrain, playerId, headRow, headCol) {
|
||||
// Schlangen-Regel: nur Felder die an den Kopf grenzen sind erreichbar
|
||||
if (headRow !== undefined && headCol !== undefined) {
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=headRow+dr, c=headCol+dc;
|
||||
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===0&&terrain[r][c]!==T_ROCK) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Fallback (alte Spiele ohne head)
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++)
|
||||
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Score auf dem vollständigen (ungemaskten) Grid berechnen
|
||||
function calcScore(grid, terrain, playerId) {
|
||||
let score = 0;
|
||||
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] !== playerId) continue;
|
||||
if (terrain[r][c] === T_GOLD) score += 3;
|
||||
else score += 1; // Mine zählt als normales Feld (+1), Strafe kommt durch Explosion der Nachbarn
|
||||
}
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
// ── Minenexplosion: alle 8 Nachbarn der Mine werden zerstört ─────────────────
|
||||
// Gibt zurück: { grid, destroyedByOwner, destroyedByOpp, blastCells }
|
||||
// Kettenexplosion: Mine explodiert, alle 8 Nachbarn werden zu Felsen.
|
||||
// Wenn ein Nachbar auch eine Mine ist, explodiert diese ebenfalls (rekursiv).
|
||||
// exploded = Set von bereits gezündeten Minen-Positionen (verhindert Endlosschleife)
|
||||
function explodeMine(grid, terrain, row, col, ownerId, oppId, exploded = new Set()) {
|
||||
const key = `${row},${col}`;
|
||||
if (exploded.has(key)) return { destroyedByOwner:0, destroyedByOpp:0, blastCells:[] };
|
||||
exploded.add(key);
|
||||
|
||||
let destroyedByOwner = 0;
|
||||
let destroyedByOpp = 0;
|
||||
const blastCells = [];
|
||||
const chainMines = [];
|
||||
|
||||
// Das Mine-Feld selbst wird auch zu Felsen
|
||||
grid[row][col] = 0;
|
||||
terrain[row][col] = T_ROCK;
|
||||
blastCells.push([row, col]);
|
||||
|
||||
// Alle 8 Nachbarn werden zu Felsen — erst Ketten-Minen merken, dann umwandeln
|
||||
for (const [dr,dc] of DIRS8) {
|
||||
const r=row+dr, c=col+dc;
|
||||
if (r<0||r>=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;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
||||
if (grid[r][c] === me) {
|
||||
const outside = Math.abs(r-row)>1 || 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;
|
||||
191
backend/src/tools/kalkulator3d/routes.js
Normal file
191
backend/src/tools/kalkulator3d/routes.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// Tool: 3D-Druck-Kalkulator
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid);
|
||||
|
||||
const baseFolder = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(uid);
|
||||
let filesMap = {};
|
||||
if (baseFolder) {
|
||||
const rows = db.prepare(`
|
||||
SELECT f.name, COUNT(fi.id) AS cnt FROM folders f
|
||||
LEFT JOIN files fi ON fi.folder_id = f.id
|
||||
WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id
|
||||
`).all(baseFolder.id, uid);
|
||||
for (const r of rows) filesMap[r.name] = r.cnt > 0;
|
||||
}
|
||||
|
||||
// Geteilt mit mir
|
||||
const shared = db.prepare(`
|
||||
SELECT c.*, u.username as owner_name, 1 as is_shared
|
||||
FROM calculations c
|
||||
JOIN calculation_shares cs ON cs.calc_id = c.id
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE cs.shared_with = ?
|
||||
ORDER BY c.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
// Geteilt von mir
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT c.*, u.username as shared_with_name, cs.created_at as shared_at
|
||||
FROM calculations c
|
||||
JOIN calculation_shares cs ON cs.calc_id = c.id
|
||||
JOIN users u ON u.id = cs.shared_with
|
||||
WHERE c.user_id = ?
|
||||
ORDER BY c.name ASC
|
||||
`).all(uid);
|
||||
|
||||
// has_files auch für shared items prüfen (anhand owner's Ordner)
|
||||
const sharedWithFiles = shared.map(i => {
|
||||
const ownerBase = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(i.user_id);
|
||||
let has_files = false;
|
||||
if (ownerBase) {
|
||||
const sub = db.prepare(
|
||||
'SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?'
|
||||
).get(i.user_id, i.name, ownerBase.id);
|
||||
if (sub) {
|
||||
const cnt = db.prepare('SELECT COUNT(*) AS c FROM files WHERE folder_id=?').get(sub.id);
|
||||
has_files = cnt.c > 0;
|
||||
}
|
||||
}
|
||||
return { ...i, has_files };
|
||||
});
|
||||
|
||||
res.json({
|
||||
own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
|
||||
shared: sharedWithFiles,
|
||||
sharedByMe: sharedByMe.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
|
||||
});
|
||||
});
|
||||
|
||||
// Dateien eines Modells abrufen (für Owner + shared users)
|
||||
router.get('/:id/files', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = calc.user_id === uid;
|
||||
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const baseFolder = db.prepare(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(calc.user_id);
|
||||
if (!baseFolder) return res.json([]);
|
||||
const sub = db.prepare('SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?')
|
||||
.get(calc.user_id, calc.name, baseFolder.id);
|
||||
if (!sub) return res.json([]);
|
||||
res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY created_at ASC').all(sub.id));
|
||||
});
|
||||
|
||||
// Einzelne Datei herunterladen (Owner + shared users)
|
||||
router.get('/:id/files/:fileId/download', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = calc.user_id === uid;
|
||||
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.fileId);
|
||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
const fp = path.join(UPLOAD_DIR, file.filename);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
|
||||
res.download(fp, file.originalname);
|
||||
});
|
||||
|
||||
|
||||
// Share-Management
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, cs.created_at as shared_at
|
||||
FROM calculation_shares cs JOIN users u ON u.id = cs.shared_with
|
||||
WHERE cs.calc_id = ?
|
||||
`).all(calc.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare("INSERT OR IGNORE INTO calculation_shares (calc_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))")
|
||||
.run(calc.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM calculation_shares WHERE calc_id=? AND shared_with=?').run(calc.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag,
|
||||
image = null, bemerkung = '' } = req.body;
|
||||
|
||||
if (!name || gramm == null || stunden == null)
|
||||
return res.status(400).json({ error: 'Name, Gramm und Stunden erforderlich' });
|
||||
|
||||
const r = db.prepare(`INSERT INTO calculations
|
||||
(user_id,name,gramm,stunden,farben,materialpreis_pro_gramm,stromverbrauch_kw,
|
||||
strompreis_pro_kwh,druckerpreis,gesamtdruckstunden,verschleiss_pro_stunde,
|
||||
preis_freundschaft,preis_normal,preis_auftrag,image,bemerkung)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(req.user.id, name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const { name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag,
|
||||
image = ex.image, bemerkung = ex.bemerkung } = req.body;
|
||||
|
||||
db.prepare(`UPDATE calculations SET
|
||||
name=?,gramm=?,stunden=?,farben=?,materialpreis_pro_gramm=?,stromverbrauch_kw=?,
|
||||
strompreis_pro_kwh=?,druckerpreis=?,gesamtdruckstunden=?,verschleiss_pro_stunde=?,
|
||||
preis_freundschaft=?,preis_normal=?,preis_auftrag=?,image=?,bemerkung=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?`)
|
||||
.run(name, gramm, stunden, farben,
|
||||
materialpreis_pro_gramm, stromverbrauch_kw, strompreis_pro_kwh,
|
||||
druckerpreis, gesamtdruckstunden, verschleiss_pro_stunde,
|
||||
preis_freundschaft, preis_normal, preis_auftrag, image, bemerkung,
|
||||
req.params.id, req.user.id);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM calculations WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
155
backend/src/tools/kanban/routes.js
Normal file
155
backend/src/tools/kanban/routes.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS kanban_columns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#4ecdc4',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kanban_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
column_id INTEGER NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'none',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
// Nachrüsten falls Tabelle schon existiert aber color fehlt
|
||||
const cols = db.pragma('table_info(kanban_columns)').map(c => c.name);
|
||||
if (!cols.includes('color')) {
|
||||
db.exec("ALTER TABLE kanban_columns ADD COLUMN color TEXT NOT NULL DEFAULT '#4ecdc4'");
|
||||
}
|
||||
})();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Alle Spalten + Karten laden ───────────────────────────────────────────────
|
||||
router.get('/board', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const columns = db.prepare(
|
||||
'SELECT * FROM kanban_columns WHERE user_id=? ORDER BY position ASC, id ASC'
|
||||
).all(me);
|
||||
const cards = db.prepare(
|
||||
'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC'
|
||||
).all(me);
|
||||
const colMap = {};
|
||||
for (const c of columns) { c.cards = []; colMap[c.id] = c; }
|
||||
for (const card of cards) {
|
||||
if (colMap[card.column_id]) colMap[card.column_id].cards.push(card);
|
||||
}
|
||||
res.json({ columns });
|
||||
});
|
||||
|
||||
// ── Spalte erstellen ──────────────────────────────────────────────────────────
|
||||
router.post('/columns', authenticate, (req, res) => {
|
||||
const { title, color = '#4ecdc4' } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
const me = uid(req);
|
||||
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me);
|
||||
const pos = (maxPos?.m ?? -1) + 1;
|
||||
const r = db.prepare(
|
||||
"INSERT INTO kanban_columns (user_id,title,color,position) VALUES (?,?,?,?)"
|
||||
).run(me, title.trim(), color, pos);
|
||||
res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// ── Spalte aktualisieren (Titel + Farbe) ─────────────────────────────────────
|
||||
// Feste Route vor :param (Lesson Learned)
|
||||
router.post('/columns/reorder', authenticate, (req, res) => {
|
||||
const { order } = req.body;
|
||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' });
|
||||
const me = uid(req);
|
||||
const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => { for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.patch('/columns/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
const title = req.body.title !== undefined ? req.body.title.trim() : col.title;
|
||||
const color = req.body.color !== undefined ? req.body.color : col.color;
|
||||
if (!title) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
db.prepare('UPDATE kanban_columns SET title=?, color=? WHERE id=?').run(title, color, col.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/columns/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
db.prepare('DELETE FROM kanban_columns WHERE id=?').run(col.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Karten ────────────────────────────────────────────────────────────────────
|
||||
router.post('/cards', authenticate, (req, res) => {
|
||||
const { column_id, title, description = '', priority = 'none' } = req.body;
|
||||
if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' });
|
||||
const me = uid(req);
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(column_id, me);
|
||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_cards WHERE column_id=?').get(column_id);
|
||||
const pos = (maxPos?.m ?? -1) + 1;
|
||||
const r = db.prepare(
|
||||
"INSERT INTO kanban_cards (column_id,user_id,title,description,priority,position) VALUES (?,?,?,?,?,?)"
|
||||
).run(column_id, me, title.trim(), description.trim(), priority, pos);
|
||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.patch('/cards/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
const title = req.body.title !== undefined ? req.body.title.trim() : card.title;
|
||||
const description = req.body.description !== undefined ? req.body.description.trim() : card.description;
|
||||
const priority = req.body.priority !== undefined ? req.body.priority : card.priority;
|
||||
if (!title) return res.status(400).json({ error: 'Titel darf nicht leer sein' });
|
||||
db.prepare(
|
||||
"UPDATE kanban_cards SET title=?,description=?,priority=?,updated_at=datetime('now','localtime') WHERE id=?"
|
||||
).run(title, description, priority, card.id);
|
||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id));
|
||||
});
|
||||
|
||||
router.delete('/cards/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
db.prepare('DELETE FROM kanban_cards WHERE id=?').run(card.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/cards/:id/move', authenticate, (req, res) => {
|
||||
const { column_id, position } = req.body;
|
||||
const me = uid(req);
|
||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||
const targetColId = column_id !== undefined ? column_id : card.column_id;
|
||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me);
|
||||
if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' });
|
||||
const siblings = db.prepare(
|
||||
'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC'
|
||||
).all(targetColId, card.id).map(r => r.id);
|
||||
const targetPos = position !== undefined
|
||||
? Math.max(0, Math.min(position, siblings.length))
|
||||
: siblings.length;
|
||||
siblings.splice(targetPos, 0, card.id);
|
||||
const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?');
|
||||
db.transaction(() => { for (let i = 0; i < siblings.length; i++) upd.run(targetColId, i, siblings[i]); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
954
backend/src/tools/koepi/routes.js
Normal file
954
backend/src/tools/koepi/routes.js
Normal file
@@ -0,0 +1,954 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../../db');
|
||||
const mqttClient = require('../../mqtt');
|
||||
const { logPush } = require('../../pushLog');
|
||||
const { logPublicAccess } = require('../../publicAccessLog');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
const cache = {};
|
||||
function cacheGet(k) { const e=cache[k]; if(!e||Date.now()>e.ex) { delete cache[k]; return null; } return e.d; }
|
||||
function cacheSet(k,d,ms=60*60*1000) { cache[k]={d,ex:Date.now()+ms}; }
|
||||
|
||||
// ── marktguru Cookie (Standort Duisburg 47259) ────────────────────────────────
|
||||
const MG_SETTINGS = encodeURIComponent(JSON.stringify({
|
||||
location: { name:'Duisburg', uniqueName:'duisburg', longitude:6.7625, latitude:51.4332, zipCodes:['47259'] },
|
||||
locationSource: 'manual', hidePageflipMenu: false, cookieAccept: true, userGroup: 35,
|
||||
}));
|
||||
|
||||
// ── Gewünschte Händler (grobe Vorfilterung, bevor die genaue Filiale geprüft wird) ─
|
||||
const TARGET_RETAILERS = ['edeka','e center','rewe','netto','kaufland','penny','trinkgut','lidl','aldi','hornbach'];
|
||||
function isTargetRetailer(name) {
|
||||
if (!name) return false;
|
||||
const n = name.toLowerCase();
|
||||
return TARGET_RETAILERS.some(r => n.includes(r));
|
||||
}
|
||||
|
||||
// Straßennamen robust vergleichbar machen (Groß-/Kleinschreibung, "straße"/"str.",
|
||||
// Kommas/Leerzeichen/Punkte egal)
|
||||
function normalizeAddress(s) {
|
||||
return (s || '')
|
||||
.toLowerCase()
|
||||
.replace(/straße|strasse/g, 'str')
|
||||
.replace(/[^a-zäöüß0-9]/g, '');
|
||||
}
|
||||
// Adressen mit mehreren kommagetrennten Hausnummern (z.B. "Straße 173,173a")
|
||||
// in einzelne vollständige Varianten zerlegen ("Straße 173" / "Straße 173a"),
|
||||
// damit ein Treffer gegen nur eine der beiden Hausnummern trotzdem zählt
|
||||
function expandAddressVariants(addr) {
|
||||
const m = (addr || '').match(/^(.*?)(\d.*)$/);
|
||||
if (!m) return [addr];
|
||||
const streetPart = m[1].trim();
|
||||
const numbers = m[2].split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (numbers.length <= 1) return [addr];
|
||||
return numbers.map(n => `${streetPart} ${n}`);
|
||||
}
|
||||
function addressesMatch(a, b) {
|
||||
for (const va of expandAddressVariants(a)) {
|
||||
for (const vb of expandAddressVariants(b)) {
|
||||
const na = normalizeAddress(va), nb = normalizeAddress(vb);
|
||||
if (na && nb && (na.includes(nb) || nb.includes(na))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Die gewünschten Filialen. Zwei Wege, wie eine Filiale hier eingetragen wird:
|
||||
// - leafletId bekannt (Prospekt-Link bestätigt): wird direkt aufgelöst.
|
||||
// - nur addressMatch bekannt: wird automatisch unter allen AKTUELL laufenden
|
||||
// Prospekten dieser Kette in der Umgebung gesucht — die closestStore-Adresse
|
||||
// wird gegen die angegebene Zieladresse abgeglichen, kein Prospekt-Link nötig.
|
||||
const LOCAL_STORE_SEED_LEAFLETS = [
|
||||
{ retailer: 'Netto Marken-Discount', leafletId: 5653691 }, // Im Bonnefeld 19
|
||||
{ retailer: 'Netto Getränke-Discount', leafletId: 5627267 }, // Im Bonnefeld 21
|
||||
{ retailer: 'trinkgut', leafletId: 5634593 }, // Keniastraße 39
|
||||
{ retailer: 'REWE', leafletId: 5616164 }, // Fischerstr. 110-112
|
||||
{ retailer: 'Kaufland', leafletId: 5628827 }, // Auf der Höhe 20
|
||||
{ retailer: 'Penny', leafletId: 5638286 }, // Sittardsberger Allee 10
|
||||
{ retailer: 'E center', leafletId: 5637005 }, // Düsseldorfer Landstr. 361
|
||||
];
|
||||
const LOCAL_STORE_SEED_BY_ADDRESS = [
|
||||
{ retailer: 'Lidl', matchRetailerKey: 'lidl', addressMatch: 'Mündelheimer Str. 184' },
|
||||
{ retailer: 'ALDI Süd', matchRetailerKey: 'aldi', addressMatch: 'Mündelheimer Straße 173' },
|
||||
{ retailer: 'HORNBACH', matchRetailerKey: 'hornbach', addressMatch: 'Düsseldorfer Straße 400' },
|
||||
];
|
||||
|
||||
function isKnownLocalStore(storeId) {
|
||||
if (storeId == null) return true; // Store konnte nicht ermittelt werden -> lieber behalten als faelschlich verstecken
|
||||
return !!db.prepare('SELECT 1 FROM koepi_local_stores WHERE store_id=?').get(storeId);
|
||||
}
|
||||
|
||||
// Explizit unerwünschte Marken/Franchise-Label, unabhängig von der Filiale —
|
||||
// zusätzliches Sicherheitsnetz, greift aber durch die ID-Whitelist ohnehin kaum
|
||||
// noch (REWE Dortmund taucht dort schlicht nicht auf)
|
||||
const EXCLUDED_RETAILER_NAMES = ['rewe dortmund'];
|
||||
function isExcludedRetailer(retailer) {
|
||||
const n = (retailer || '').toLowerCase();
|
||||
return EXCLUDED_RETAILER_NAMES.some(x => n.includes(x));
|
||||
}
|
||||
|
||||
// ── Angebote von marktguru ────────────────────────────────────────────────────
|
||||
// Primär über die öffentlich bekannte marktguru-JSON-API (schnell, kein Browser
|
||||
// nötig, kein Cloudflare-Risiko). Schlägt das fehl (z.B. weil die Keys mal
|
||||
// ungültig werden), fällt der Code automatisch auf das alte Puppeteer/HTML-
|
||||
// Scraping zurück, damit KöPi nicht komplett ausfällt.
|
||||
const MARKTGURU_API_URL = 'https://api.marktguru.de/api/v1/offers/search';
|
||||
const MARKTGURU_CLIENT_KEY = 'WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=';
|
||||
const MARKTGURU_API_KEY = '8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=';
|
||||
const MG_ZIP = '47259';
|
||||
const MG_QUERY = 'könig pilsener';
|
||||
|
||||
function formatIsoDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
|
||||
}
|
||||
|
||||
// Duisburg 47259 Zentrum — für den closestStore-Abgleich im leaflets-Detail-Call.
|
||||
// Innerhalb der zipCode-Filterung spielt der genaue Punkt kaum eine Rolle.
|
||||
const MG_LAT = '51.3543213';
|
||||
const MG_LON = '6.7151937';
|
||||
|
||||
const mgHeaders = {
|
||||
'x-clientkey': MARKTGURU_CLIENT_KEY,
|
||||
'x-apikey': MARKTGURU_API_KEY,
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
// Holt für eine PLZ alle laufenden Prospekt-Kampagnen (leafletFlights) und baut
|
||||
// eine Zuordnung leafletFlightId -> mainLeafletId, die wir brauchen, um darüber
|
||||
// später das leaflet-Detail (inkl. closestStore) abzurufen.
|
||||
async function fetchLeafletFlightToLeafletMap() {
|
||||
const url = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
|
||||
const res = await fetch(url, { headers: mgHeaders });
|
||||
if (!res.ok) throw new Error(`leafletflights HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const map = new Map();
|
||||
for (const f of (json.results || [])) {
|
||||
if (f.id && f.mainLeafletId) map.set(f.id, f.mainLeafletId);
|
||||
}
|
||||
console.log(`🍺 KöPi: ${map.size} leafletFlight→Leaflet Zuordnungen geladen (von ${json.totalResults} gesamt)`);
|
||||
return map;
|
||||
}
|
||||
|
||||
// Holt für ein einzelnes Leaflet den kompletten Datensatz von marktguru:
|
||||
// closestStore (id/address/distanceInMeters — Grundlage der Filial-Whitelist),
|
||||
// name (enthält oft "(KW29 Do-Sa)" o.ä.) und children (Liste aller Angebote
|
||||
// mit ihrem pageIndex, für den seiten-genauen Direktlink).
|
||||
async function fetchLeafletDetail(leafletId, cache) {
|
||||
if (cache.has(leafletId)) return cache.get(leafletId);
|
||||
const empty = { id: leafletId, name: '', closestStore: { id: null, address: '', distanceMeters: null }, children: [] };
|
||||
try {
|
||||
const url = `https://api.marktguru.de/api/v1/leaflets/${leafletId}?as=mobiledetailed&latitude=${MG_LAT}&longitude=${MG_LON}&zipCode=${MG_ZIP}`;
|
||||
const res = await fetch(url, { headers: mgHeaders });
|
||||
if (!res.ok) { cache.set(leafletId, empty); return empty; }
|
||||
const json = await res.json();
|
||||
const store = json.closestStore;
|
||||
const result = {
|
||||
id: json.id ?? leafletId,
|
||||
name: json.name || '',
|
||||
closestStore: store
|
||||
? { id: store.id ?? null, address: store.address || '', distanceMeters: store.distanceInMeters ?? null }
|
||||
: { id: null, address: '', distanceMeters: null },
|
||||
children: json.children || [],
|
||||
};
|
||||
console.log(`🍺 KöPi: Leaflet ${leafletId} ("${result.name}") → closestStore: ${store ? `id=${store.id} "${store.address}" (${Math.round(store.distanceInMeters||0)}m entfernt)` : 'keine'}`);
|
||||
cache.set(leafletId, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.log(`🍺 KöPi: Leaflet-Detail-Abruf für ${leafletId} fehlgeschlagen: ${e.message}`);
|
||||
cache.set(leafletId, empty);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahiert eine "(KW29 Do-Sa)"-artige Angabe aus dem Leaflet-Namen. Manche
|
||||
// Ketten (z.B. ALDI) schreiben die KW-Angabe ohne umschließende Klammern
|
||||
// ("... KW32 ...") — die wird dann selbst eingeklammert, damit die Anzeige
|
||||
// überall einheitlich aussieht.
|
||||
function extractWeekInfo(name) {
|
||||
// Fängt nur "KW29" bzw. "KW 29" plus optional einen kurzen Wochentag-Bereich
|
||||
// wie "Do-Sa" direkt danach ein — ignoriert bewusst alles andere drumherum
|
||||
// (z.B. technischen Datensatz-Text wie "HHZ Dataset 2"), egal ob das Ganze
|
||||
// in Klammern steht oder nicht. Klammert das Ergebnis immer selbst neu ein,
|
||||
// damit die Anzeige überall einheitlich aussieht.
|
||||
const m = (name || '').match(/KW\s*\d+(?:\s*[A-Za-zÄÖÜäöüß]{2,3}-[A-Za-zÄÖÜäöüß]{2,3})?/i);
|
||||
return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : '';
|
||||
}
|
||||
|
||||
// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel:
|
||||
// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als
|
||||
// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort
|
||||
// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran).
|
||||
const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi];
|
||||
function cleanTitle(name) {
|
||||
let t = name || '';
|
||||
for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, '');
|
||||
return t.replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert:
|
||||
// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen
|
||||
function getISOWeekNumber(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
|
||||
const yearStart = new Date(d.getFullYear(), 0, 1);
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
function weekInfoWithFallback(name, validFromIso) {
|
||||
const found = extractWeekInfo(name);
|
||||
if (found) return found;
|
||||
const week = validFromIso ? getISOWeekNumber(validFromIso) : null;
|
||||
return week ? `(KW${week})` : '';
|
||||
}
|
||||
|
||||
// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig
|
||||
// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim
|
||||
// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann
|
||||
// über den Admin-Button jederzeit erneut angestoßen werden, falls sich mal ein
|
||||
// Prospekt-Link ändert oder eine Filiale ergänzt werden soll.
|
||||
async function resolveLocalStores() {
|
||||
const cache = new Map();
|
||||
let resolved = 0;
|
||||
|
||||
// 1) Direkt bekannte Leaflet-IDs (per Prospekt-Link bestätigt)
|
||||
for (const seed of LOCAL_STORE_SEED_LEAFLETS) {
|
||||
const detail = await fetchLeafletDetail(seed.leafletId, cache);
|
||||
const store = detail.closestStore;
|
||||
if (store.id != null) {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
`).run(store.id, seed.retailer, store.address);
|
||||
console.log(`🍺 KöPi: Filiale aufgelöst — ${seed.retailer} → Store-ID ${store.id} ("${store.address}")`);
|
||||
resolved++;
|
||||
} else {
|
||||
console.log(`🍺 KöPi: Filiale NICHT auflösbar — ${seed.retailer} (Leaflet ${seed.leafletId}) — evtl. Prospekt abgelaufen, Link erneuern`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Nur Adresse bekannt — unter allen aktuell laufenden Prospekten dieser
|
||||
// Kette in der Umgebung suchen, closestStore-Adresse gegen Zieladresse prüfen
|
||||
if (LOCAL_STORE_SEED_BY_ADDRESS.length) {
|
||||
try {
|
||||
const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
|
||||
const flightsRes = await fetch(flightsUrl, { headers: mgHeaders });
|
||||
const flightsJson = flightsRes.ok ? await flightsRes.json() : { results: [] };
|
||||
|
||||
for (const target of LOCAL_STORE_SEED_BY_ADDRESS) {
|
||||
const candidates = (flightsJson.results || []).filter(f =>
|
||||
(f.advertiser?.name || '').toLowerCase().includes(target.matchRetailerKey)
|
||||
);
|
||||
console.log(`🍺 KöPi: Suche "${target.retailer}" per Adresse — ${candidates.length} laufende Prospekt(e) dieser Kette gefunden`);
|
||||
let found = null;
|
||||
for (const c of candidates) {
|
||||
if (!c.mainLeafletId) continue;
|
||||
const detail = await fetchLeafletDetail(c.mainLeafletId, cache);
|
||||
const store = detail.closestStore;
|
||||
if (store.id != null && addressesMatch(store.address, target.addressMatch)) {
|
||||
found = store;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO koepi_local_stores (store_id, retailer, address, resolved_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
`).run(found.id, target.retailer, found.address);
|
||||
console.log(`🍺 KöPi: Filiale per Adresse gefunden — ${target.retailer} → Store-ID ${found.id} ("${found.address}")`);
|
||||
resolved++;
|
||||
} else {
|
||||
console.log(`🍺 KöPi: Filiale per Adresse NICHT gefunden — ${target.retailer} (gesucht: "${target.addressMatch}") — evtl. gerade kein laufender Prospekt dieser Kette in der Gegend`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('🍺 KöPi: Adress-basierte Filial-Suche fehlgeschlagen:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function fetchOffersViaApi() {
|
||||
const url = `${MARKTGURU_API_URL}?as=web&limit=24&offset=0&q=${encodeURIComponent(MG_QUERY)}&zipCode=${MG_ZIP}`;
|
||||
const res = await fetch(url, { headers: mgHeaders });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`API HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
console.log('🍺 KöPi API: totalResults=', json.totalResults, '| results.length=', json.results?.length);
|
||||
|
||||
const rawOffers = json.results || [];
|
||||
|
||||
// Echte Filial-ID + Adresse pro Angebot auflösen (leafletFlightId -> mainLeafletId -> closestStore)
|
||||
let flightToLeaflet = new Map();
|
||||
try {
|
||||
flightToLeaflet = await fetchLeafletFlightToLeafletMap();
|
||||
} catch (e) {
|
||||
console.log('🍺 KöPi: leafletflights-Abruf fehlgeschlagen, Filial-Filter bleibt diesmal wirkungslos:', e.message);
|
||||
}
|
||||
const leafletCache = new Map();
|
||||
const detailByOfferId = new Map();
|
||||
for (const r of rawOffers) {
|
||||
const leafletId = flightToLeaflet.get(r.leafletFlightId);
|
||||
if (leafletId) {
|
||||
const detail = await fetchLeafletDetail(leafletId, leafletCache);
|
||||
detailByOfferId.set(r.id, detail);
|
||||
}
|
||||
}
|
||||
|
||||
return rawOffers.map(r => {
|
||||
const advertiser = r.advertisers?.[0] || {};
|
||||
const priceNum = typeof r.price === 'number' ? r.price : parseFloat(r.price);
|
||||
const oldPriceNum = typeof r.oldPrice === 'number' ? r.oldPrice : parseFloat(r.oldPrice);
|
||||
const detail = detailByOfferId.get(r.id);
|
||||
const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null };
|
||||
const validity0 = r.validityDates?.[0] || {};
|
||||
const weekInfo = weekInfoWithFallback(detail?.name, validity0.from);
|
||||
const baseDateRange = validity0.from && validity0.to
|
||||
? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}`
|
||||
: formatIsoDate(validity0.to);
|
||||
// Seiten-genauer Direktlink zum Angebot innerhalb des Prospekts — wird bei
|
||||
// jedem Scrape frisch aus den aktuellen API-Daten ermittelt (children[].id
|
||||
// == "offers/{id}"), funktioniert also automatisch weiter bei neuen
|
||||
// Prospekten, ohne dass irgendwas hartkodiert werden muss.
|
||||
const child = detail?.children?.find(c => c.id === `offers/${r.id}`);
|
||||
const leafletUrl = detail
|
||||
? `https://www.marktguru.de/leaflets/${detail.id}${child ? `/page/${child.pageIndex}` : ''}`
|
||||
: null;
|
||||
const knownStore = storeInfo.id != null
|
||||
? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(storeInfo.id)
|
||||
: null;
|
||||
const retailerLabel = knownStore?.retailer || advertiser.name || '';
|
||||
return {
|
||||
name: r.description || r.title || advertiser.name || '',
|
||||
brand: r.brand?.name || '',
|
||||
price: Number.isFinite(priceNum) ? `€ ${priceNum.toFixed(2).replace('.', ',')}` : (r.price || ''),
|
||||
oldPrice: Number.isFinite(oldPriceNum) ? `€ ${oldPriceNum.toFixed(2).replace('.', ',')}` : (r.oldPrice || ''),
|
||||
retailer: retailerLabel,
|
||||
dateRange: weekInfo ? `${baseDateRange} ${weekInfo}` : baseDateRange,
|
||||
weekInfo,
|
||||
validity: '',
|
||||
description: r.description || '',
|
||||
badge: '',
|
||||
// Adresse kommt jetzt direkt von marktguru (closestStore.address), nicht mehr fest hinterlegt
|
||||
address: storeInfo.address || '',
|
||||
realAddress: storeInfo.address || '',
|
||||
storeId: storeInfo.id,
|
||||
storeDistanceMeters: storeInfo.distanceMeters,
|
||||
leafletFlightId: r.leafletFlightId || null,
|
||||
offerId: r.id || null,
|
||||
leafletUrl,
|
||||
image: r.image?.url || r.images?.[0]?.url || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOffersViaPuppeteer() {
|
||||
let puppeteer;
|
||||
try { puppeteer = require('puppeteer-core'); }
|
||||
catch(e) { throw new Error('puppeteer-core nicht installiert'); }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: '/usr/bin/chromium-browser',
|
||||
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--disable-gpu','--single-process'],
|
||||
headless: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
await page.setViewport({ width:1280, height:800 });
|
||||
await page.setCookie({ name:'mg_user-settings', value:MG_SETTINGS, domain:'.marktguru.de', path:'/' });
|
||||
|
||||
await page.goto('https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259', {
|
||||
waitUntil: 'networkidle2', timeout: 30000,
|
||||
});
|
||||
// Seite nach unten scrollen damit lazy-loaded Bilder geladen werden
|
||||
await page.evaluate(async () => {
|
||||
await new Promise(resolve => {
|
||||
let totalHeight = 0;
|
||||
const distance = 300;
|
||||
const timer = setInterval(() => {
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= document.body.scrollHeight) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const offers = await page.evaluate(() => {
|
||||
// Exakte Selektoren basierend auf marktguru HTML-Struktur
|
||||
const cards = document.querySelectorAll('li.offer-list-item');
|
||||
return Array.from(cards).map(card => {
|
||||
const name = card.querySelector('h3')?.textContent?.trim() || '';
|
||||
const brand = card.querySelector('dd.brand a')?.textContent?.trim() || '';
|
||||
const price = card.querySelector('span.price')?.textContent?.trim() || '';
|
||||
const oldPrice = card.querySelector('.price-bubble .old-price, .crossed')?.textContent?.trim() || '';
|
||||
const retailer = card.querySelector('dd.retailer-name a')?.textContent?.trim() || '';
|
||||
const dateRange= card.querySelector('dd.valid')?.textContent?.trim() || '';
|
||||
const daysLeft = card.querySelector('dd.time-left span')?.textContent?.trim() || '';
|
||||
const validity = daysLeft ? `Noch ${daysLeft} Tag${daysLeft==='1'?'':'e'}` :
|
||||
card.querySelector('dt.time-left')?.textContent?.includes('Brandneu') ? 'Neu' : '';
|
||||
const descEl = card.querySelector('.info div, .info');
|
||||
const description = descEl?.textContent?.trim() || '';
|
||||
const img = card.querySelector('img.offer-list-item-img');
|
||||
const badge = card.querySelector('.badge, .discount-badge')?.textContent?.trim() || '';
|
||||
const imgSrc = img?.src?.replace('/medium.webp', '/large.webp') || img?.src || null;
|
||||
const allDds = Array.from(card.querySelectorAll('dd'));
|
||||
const retailerIdx = allDds.findIndex(d => d.classList.contains('retailer-name'));
|
||||
let realAddress = '';
|
||||
if (retailerIdx !== -1 && allDds[retailerIdx + 1]) {
|
||||
const next = allDds[retailerIdx + 1];
|
||||
const isOtherField = next.classList.contains('valid') || next.classList.contains('time-left');
|
||||
if (!isOtherField) realAddress = next.textContent?.trim() || '';
|
||||
}
|
||||
return { name, brand, price, oldPrice, retailer, dateRange, validity, description, badge, address: '', realAddress, image: imgSrc };
|
||||
});
|
||||
});
|
||||
return offers;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Prüft, ob "COUNT x VOL" (z.B. "20 x 0,5") in der Beschreibung vorkommt —
|
||||
// robust gegen Leerzeichen um das "x", Komma/Punkt als Dezimaltrennzeichen,
|
||||
// und verhindert Fehltreffer wie "0,5" innerhalb von "0,55"
|
||||
function hasQty(desc, count, vol) {
|
||||
const volPattern = vol.replace(',', '[,.]');
|
||||
const re = new RegExp(`${count}\\s*x\\s*${volPattern}(?!\\d)`, 'i');
|
||||
return re.test(desc);
|
||||
}
|
||||
|
||||
// Bild pro Angebot exakt nach Mengenangabe wählen (siehe Vorgabe):
|
||||
// 20x0,5 -> kasten_050 | 20x0,33 -> kasten_steini | 24x0,33 -> kasten_033
|
||||
// 11x0,5 -> kasten_11er | 6x0,33 -> traeger | 24x0,5 -> palette_050
|
||||
// 20x0,5 UND 24x0,33 gleichzeitig -> kasten_033_05 | sonst -> koepi_platzhalter
|
||||
function pickOfferImage(description) {
|
||||
const d = (description || '').toLowerCase();
|
||||
const q2005 = hasQty(d, 20, '0,5');
|
||||
const q2433 = hasQty(d, 24, '0,33');
|
||||
if (q2005 && q2433) return '/koepi/kasten_033_05.png';
|
||||
if (hasQty(d, 20, '0,33')) return '/koepi/kasten_steini.png';
|
||||
if (q2433) return '/koepi/kasten_033.png';
|
||||
if (hasQty(d, 11, '0,5')) return '/koepi/kasten_11er.png';
|
||||
if (hasQty(d, 6, '0,33')) return '/koepi/traeger.png';
|
||||
if (hasQty(d, 24, '0,5')) return '/koepi/palette_050.png';
|
||||
if (q2005) return '/koepi/kasten_050.png';
|
||||
return '/koepi/koepi_platzhalter.png';
|
||||
}
|
||||
|
||||
// Gemeinsame Weiterverarbeitung, egal ob die Rohdaten über die API oder per
|
||||
// Puppeteer/HTML-Scraping ermittelt wurden
|
||||
function processOffers(offers) {
|
||||
// Lokale Bilder auswählen basierend auf Beschreibung
|
||||
for (const offer of offers) {
|
||||
offer.imageLocal = pickOfferImage(offer.description);
|
||||
offer.image = null; // CDN nicht mehr nötig
|
||||
}
|
||||
|
||||
// Filtern auf Ziel-Händler + nur König Pilsener
|
||||
const filtered = offers.filter(o => {
|
||||
const isKP = (o.name + ' ' + o.brand + ' ' + o.description).toLowerCase();
|
||||
const hasKP = isKP.includes('pilsen') || isKP.includes('pilsener');
|
||||
return hasKP && isTargetRetailer(o.retailer);
|
||||
});
|
||||
|
||||
const excludedNow = filtered.filter(o => isExcludedRetailer(o.retailer));
|
||||
if (excludedNow.length) {
|
||||
console.log('🍺 KöPi: Angebote per Namens-Filter ausgeschlossen:', excludedNow.map(o => o.retailer).join(', '));
|
||||
}
|
||||
|
||||
console.log('🍺 KöPi Filial-Whitelist-Diagnose:', filtered.map(o =>
|
||||
`${o.retailer} | Store-ID=${o.storeId ?? 'unbekannt'} | ${o.realAddress||'keine Adresse'} | bekannt=${isKnownLocalStore(o.storeId)}`
|
||||
).join('\n '));
|
||||
|
||||
const localOnly = filtered
|
||||
.filter(o => !isExcludedRetailer(o.retailer))
|
||||
.filter(o => isKnownLocalStore(o.storeId));
|
||||
|
||||
for (const o of localOnly) {
|
||||
o.retailerDisplay = o.retailer;
|
||||
}
|
||||
|
||||
// Deduplizieren: gleicher normalisierter Händler + gleicher Preis + gleiche
|
||||
// Filiale → ein Eintrag
|
||||
const merged = new Map();
|
||||
for (const o of localOnly) {
|
||||
const normalizedRetailer = o.retailer?.toLowerCase().replace('e center','edeka') || '';
|
||||
const key = `${normalizedRetailer}|${o.price}|${o.storeId ?? ''}`;
|
||||
if (merged.has(key)) {
|
||||
const existing = merged.get(key);
|
||||
if (o.description && (!existing.description || o.description.length < existing.description.length)) {
|
||||
existing.description = o.description;
|
||||
}
|
||||
} else {
|
||||
merged.set(key, { ...o });
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function scrapeMarktguru(forceRefresh = false) {
|
||||
const cacheKey = 'koepi:mg';
|
||||
if (!forceRefresh) {
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// Filial-Whitelist beim allerersten Lauf automatisch auflösen (Tabelle leer)
|
||||
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
|
||||
if (knownCount === 0) {
|
||||
console.log('🍺 KöPi: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
|
||||
try { await resolveLocalStores(); }
|
||||
catch (e) { console.error('🍺 KöPi: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
|
||||
}
|
||||
|
||||
let offers, source;
|
||||
try {
|
||||
offers = await fetchOffersViaApi();
|
||||
source = 'marktguru.de (API)';
|
||||
console.log(`🍺 KöPi: ${offers.length} Rohtreffer über die marktguru-API`);
|
||||
} catch (apiErr) {
|
||||
console.error('🍺 KöPi: API-Weg fehlgeschlagen, falle zurück auf Puppeteer:', apiErr.message);
|
||||
try {
|
||||
offers = await fetchOffersViaPuppeteer();
|
||||
source = 'marktguru.de (Puppeteer-Fallback)';
|
||||
console.log(`🍺 KöPi: ${offers.length} Rohtreffer per Puppeteer (Fallback)`);
|
||||
} catch (ppErr) {
|
||||
console.error('🍺 KöPi: auch Puppeteer-Fallback fehlgeschlagen:', ppErr.message);
|
||||
return { offers: [], error: `API: ${apiErr.message} | Puppeteer: ${ppErr.message}`, scrapedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const unique = processOffers(offers);
|
||||
const result = { offers: unique, total: unique.length, scrapedAt: new Date().toISOString(), source };
|
||||
cacheSet(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Prospekte von marktguru (dieselbe Filial-Whitelist wie bei den Angeboten) ──
|
||||
async function scrapeProspekte() {
|
||||
const cacheKey = 'koepi:prospekte';
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const knownCount = db.prepare('SELECT COUNT(*) AS n FROM koepi_local_stores').get().n;
|
||||
if (knownCount === 0) {
|
||||
console.log('🍺 KöPi Prospekte: Filial-Whitelist ist leer, löse sie jetzt einmalig auf...');
|
||||
try { await resolveLocalStores(); }
|
||||
catch (e) { console.error('🍺 KöPi Prospekte: Auflösen der Filial-Whitelist fehlgeschlagen:', e.message); }
|
||||
}
|
||||
|
||||
const flightsUrl = `https://api.marktguru.de/api/v1/leafletflights?as=mobile&limit=100&zipCode=${MG_ZIP}`;
|
||||
const flightsRes = await fetch(flightsUrl, { headers: mgHeaders });
|
||||
if (!flightsRes.ok) throw new Error(`leafletflights HTTP ${flightsRes.status}`);
|
||||
const flightsJson = await flightsRes.json();
|
||||
console.log(`🍺 KöPi Prospekte: ${flightsJson.results?.length} von ${flightsJson.totalResults} Prospekt-Kampagnen geladen`);
|
||||
|
||||
// Nur Kampagnen der gewünschten Ketten überhaupt im Detail auflösen (spart API-Calls)
|
||||
const candidates = (flightsJson.results || []).filter(f => isTargetRetailer(f.advertiser?.name));
|
||||
|
||||
const leafletCache = new Map();
|
||||
const all = [];
|
||||
for (const f of candidates) {
|
||||
if (!f.mainLeafletId) continue;
|
||||
const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache);
|
||||
const store = detail.closestStore;
|
||||
const weekInfo = weekInfoWithFallback(detail.name, f.validFrom);
|
||||
// Store-spezifische Bezeichnung aus der Whitelist bevorzugen (z.B. "Netto
|
||||
// Getränke-Discount") statt dem generischen advertiser.name von marktguru
|
||||
// (liefert für diese Filiale fälschlich nur "Netto Marken-Discount")
|
||||
const knownStore = store.id != null
|
||||
? db.prepare('SELECT retailer FROM koepi_local_stores WHERE store_id=?').get(store.id)
|
||||
: null;
|
||||
all.push({
|
||||
id: f.id,
|
||||
publisher: knownStore?.retailer || f.advertiser?.name || '',
|
||||
title: f.advertiser?.name || cleanTitle(detail.name) || '',
|
||||
rawName: detail.name || '', // nur intern, für den Ausschluss-Filter unten
|
||||
weekInfo,
|
||||
street: store.address || '',
|
||||
zip: MG_ZIP,
|
||||
city: 'Duisburg',
|
||||
storeId: store.id,
|
||||
image: null,
|
||||
validFrom: f.validFrom || null,
|
||||
validTo: f.validTo || null,
|
||||
pageCount: f.pageCount || null,
|
||||
url: `https://www.marktguru.de/leaflets/${f.mainLeafletId}`,
|
||||
isTarget: isKnownLocalStore(store.id) && store.id != null,
|
||||
badges: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Bier-Icon: mit den aktuellen König-Pilsener-Angeboten abgleichen, welche
|
||||
// Filialen gerade wirklich König Pilsener im Angebot haben
|
||||
try {
|
||||
const offersResult = await scrapeMarktguru(false);
|
||||
const storeIdsWithKoepi = new Set((offersResult.offers || []).map(o => o.storeId).filter(id => id != null));
|
||||
for (const b of all) b.hasKoenigPilsener = b.storeId != null && storeIdsWithKoepi.has(b.storeId);
|
||||
} catch (e) {
|
||||
console.log('🍺 KöPi Prospekte: Abgleich mit aktuellen Angeboten fehlgeschlagen (ignoriert):', e.message);
|
||||
for (const b of all) b.hasKoenigPilsener = false;
|
||||
}
|
||||
|
||||
// Unerwünschte Prospekt-Typen ausblenden (Sondermagazine, Reise-Prospekte etc.)
|
||||
// WICHTIG: gegen rawName (den echten Leaflet-Namen) prüfen, nicht gegen
|
||||
// title/publisher — die zeigen nur noch den sauberen Kettennamen und würden
|
||||
// "Sondermagazin"/"Bestellmagazin" im echten Namen sonst nie mehr finden.
|
||||
const EXCLUDED_TITLE_KEYWORDS = ['sondermagazin', 'reisen', 'bestellmagazin'];
|
||||
const withoutSondermagazin = all.filter(b => {
|
||||
const t = (b.rawName || '').toLowerCase();
|
||||
return !EXCLUDED_TITLE_KEYWORDS.some(kw => t.includes(kw));
|
||||
}).map(({ rawName, ...rest }) => rest); // rawName war nur intern nötig
|
||||
const targeted = withoutSondermagazin.filter(b => b.isTarget);
|
||||
|
||||
// Chronologisch in "aktuell laufend" und "kommend" trennen
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const isCurrent = b => {
|
||||
const from = (b.validFrom || '').slice(0, 10);
|
||||
const to = (b.validTo || '').slice(0, 10);
|
||||
if (!from) return true; // ohne Datum lieber anzeigen als verstecken
|
||||
if (from > todayIso) return false; // startet erst noch
|
||||
if (to && to < todayIso) return false; // schon abgelaufen
|
||||
return true;
|
||||
};
|
||||
const byValidFrom = (a, b) => (a.validFrom || '').localeCompare(b.validFrom || '');
|
||||
const current = targeted.filter(isCurrent).sort(byValidFrom);
|
||||
const future = targeted.filter(b => !isCurrent(b)).sort(byValidFrom);
|
||||
|
||||
const result = {
|
||||
current,
|
||||
future,
|
||||
scrapedAt: new Date().toISOString(),
|
||||
};
|
||||
console.log(`🍺 KöPi Prospekte: ${current.length} aktuell laufend, ${future.length} kommend, ${current.filter(b=>b.hasKoenigPilsener).length} davon aktuell mit König Pilsener im Angebot`);
|
||||
cacheSet(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Bild-Proxy (für evtl. externe Bilder) ───────────────────────────────────
|
||||
router.get('/img', authenticate, (req, res) => {
|
||||
const url = req.query.url;
|
||||
if (!url || !url.startsWith('https://')) return res.status(400).end();
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const allowed = ['content-media.bonial.biz','publisher-media.bonial.biz'];
|
||||
if (!allowed.some(h => parsed.hostname === h)) return res.status(403).end();
|
||||
const r = https.get(url, { headers: {'User-Agent':'Mozilla/5.0'} }, imgRes => {
|
||||
res.setHeader('Content-Type', imgRes.headers['content-type'] || 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
imgRes.pipe(res);
|
||||
});
|
||||
r.on('error', () => res.status(502).end());
|
||||
} catch { res.status(400).end(); }
|
||||
});
|
||||
|
||||
// ── Tages-Cron: neue/geänderte Angebote per Pushover melden ─────────────────
|
||||
const KOEPI_SETTINGS_KEY = 'koepi_last_offers';
|
||||
|
||||
// Vergleichbare, stabile Signatur eines Angebots (Reihenfolge-unabhängig).
|
||||
// Bewusst NUR dateRange (echtes Datum), NICHT validity ("Noch X Tage") — der
|
||||
// Countdown-Text ändert sich täglich von allein und würde sonst jeden Tag
|
||||
// eine Pushover-Benachrichtigung auslösen, auch wenn sich am Angebot nichts
|
||||
// geändert hat.
|
||||
function offerSignature(offers) {
|
||||
return offers
|
||||
.map(o => `${(o.retailer || '').toLowerCase().trim()}|${(o.price || '').trim()}|${(o.dateRange || '').trim()}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatOfferMessage(offers) {
|
||||
if (!offers.length) return 'Aktuell keine König Pilsener Angebote gefunden.';
|
||||
return offers
|
||||
.map(o => `${o.retailer || '?'}: ${o.price || '?'} (${o.dateRange || 'Gültigkeit unbekannt'})`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// onlyUserId gesetzt: nur an diesen einen Nutzer schicken (z.B. Admin-Testklick),
|
||||
// sonst an alle Nutzer mit hinterlegtem Pushover (echter Tages-Cron)
|
||||
async function sendKoepiPushover(offers, onlyUserId = null) {
|
||||
const recipients = onlyUserId
|
||||
? db.prepare(`
|
||||
SELECT user_id, user_key, app_token FROM pushover_settings
|
||||
WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL
|
||||
`).all(onlyUserId)
|
||||
: db.prepare(`
|
||||
SELECT user_id, user_key, app_token FROM pushover_settings
|
||||
WHERE user_key IS NOT NULL AND app_token IS NOT NULL
|
||||
`).all();
|
||||
const message = formatOfferMessage(offers);
|
||||
const title = '🍺 KöPi Angebote';
|
||||
for (const r of recipients) {
|
||||
try {
|
||||
await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
token: r.app_token,
|
||||
user: r.user_key,
|
||||
title,
|
||||
message,
|
||||
priority: 0,
|
||||
}),
|
||||
});
|
||||
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi' });
|
||||
} catch (e) {
|
||||
console.error('KöPi Pushover Fehler:', e.message);
|
||||
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi', success: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reiner Anzeige-Refresh für den HA-Button "KöPi Check jetzt" — scraped frisch
|
||||
// und aktualisiert nur die MQTT-Entitäten. Sendet NIE Pushover und rührt den
|
||||
// in admin_settings gespeicherten Vergleichsstand nicht an (der bleibt exklusiv
|
||||
// dem 06:00-Cron vorbehalten, damit dessen Diff-Erkennung korrekt bleibt).
|
||||
async function refreshOffersOnly() {
|
||||
try {
|
||||
const { offers } = await scrapeMarktguru(true);
|
||||
const currentSig = offerSignature(offers);
|
||||
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
|
||||
const changed = currentSig !== lastSig; // nur zur Anzeige im "geändert"-Sensor, löst nichts aus
|
||||
mqttClient.publishKoepiState({ offers, changed });
|
||||
console.log(`🍺 KöPi-Refresh (HA-Button): ${offers.length} Angebot(e) angezeigt, keine Pushover.`);
|
||||
return { changed, sent: false, offerCount: offers.length };
|
||||
} catch (e) {
|
||||
console.error('KöPi Refresh Fehler:', e.message);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Wird täglich um 06:00 vom Scheduler in index.js aufgerufen (ohne onlyUserId
|
||||
// → geht an alle). forceNotify=true (manueller Admin-Test): Pushover wird auch
|
||||
// ohne Änderung verschickt; onlyUserId gesetzt: nur an diesen einen Nutzer.
|
||||
// Löst die Filial-Whitelist automatisch neu auf, wenn die letzte Auflösung
|
||||
// mehr als 6 Tage her ist — läuft als Teil des täglichen 06:00-Checks, damit
|
||||
// ein einmalig falscher/veralteter Treffer sich von selbst korrigiert, statt
|
||||
// dauerhaft stehen zu bleiben.
|
||||
const RESOLVE_MAX_AGE_DAYS = 6;
|
||||
async function resolveLocalStoresIfStale() {
|
||||
const row = db.prepare('SELECT MIN(resolved_at) AS oldest FROM koepi_local_stores').get();
|
||||
const oldest = row?.oldest;
|
||||
const staleOrMissing = !oldest ||
|
||||
(Date.now() - new Date(oldest.replace(' ', 'T')).getTime()) > RESOLVE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (!staleOrMissing) return;
|
||||
console.log(`🍺 KöPi: Filial-Whitelist ist älter als ${RESOLVE_MAX_AGE_DAYS} Tage (oder unvollständig) — löse automatisch neu auf...`);
|
||||
try { await resolveLocalStores(); }
|
||||
catch (e) { console.error('🍺 KöPi: automatische Neu-Auflösung fehlgeschlagen:', e.message); }
|
||||
}
|
||||
|
||||
async function runDailyCheck(forceNotify = false, onlyUserId = null) {
|
||||
try {
|
||||
await resolveLocalStoresIfStale();
|
||||
const { offers } = await scrapeMarktguru(true);
|
||||
const currentSig = offerSignature(offers);
|
||||
const lastSig = db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SETTINGS_KEY)?.value || '';
|
||||
const changed = currentSig !== lastSig;
|
||||
|
||||
if (!changed && !forceNotify) {
|
||||
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
|
||||
mqttClient.publishKoepiState({ offers, changed: false });
|
||||
return { changed: false, sent: false, offerCount: offers.length };
|
||||
}
|
||||
|
||||
await sendKoepiPushover(offers, onlyUserId);
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
|
||||
.run(KOEPI_SETTINGS_KEY, currentSig);
|
||||
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} – Pushover versendet${onlyUserId ? ` (nur an User ${onlyUserId})` : ''}.`);
|
||||
mqttClient.publishKoepiState({ offers, changed });
|
||||
return { changed, sent: true, offerCount: offers.length };
|
||||
} catch (e) {
|
||||
console.error('KöPi Tages-Check Fehler:', e.message);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
router.get('/offers', authenticate, async (req, res) => {
|
||||
try { res.json(await scrapeMarktguru()); }
|
||||
catch(e) { console.error('KöPi offers:', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/prospekte', authenticate, async (req, res) => {
|
||||
try { res.json(await scrapeProspekte()); }
|
||||
catch(e) {
|
||||
console.error('🍺 KöPi Prospekte-Fehler:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/clear-cache', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
['koepi:mg','koepi:prospekte'].forEach(k => delete cache[k]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Manueller Test des Tages-Checks (Admin) — sendet Pushover auch ohne echte
|
||||
// Änderung, aber NUR an den Admin selbst (nicht an alle Nutzer)
|
||||
// GET /local-stores – aktuelle Filial-Whitelist anzeigen (Admin)
|
||||
router.get('/local-stores', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
|
||||
res.json({ stores, seedCount: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length });
|
||||
});
|
||||
|
||||
// POST /local-stores/resolve – Filial-Whitelist neu auflösen (Admin, z.B. wenn
|
||||
// sich ein Prospekt-Link geändert hat oder eine Filiale ergänzt wurde)
|
||||
router.post('/local-stores/resolve', authenticate, async (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
try {
|
||||
const resolved = await resolveLocalStores();
|
||||
const stores = db.prepare('SELECT * FROM koepi_local_stores ORDER BY retailer').all();
|
||||
res.json({ ok: true, resolved, total: LOCAL_STORE_SEED_LEAFLETS.length + LOCAL_STORE_SEED_BY_ADDRESS.length, stores });
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/run-daily-check', authenticate, async (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
try {
|
||||
const result = await runDailyCheck(true, req.user.id);
|
||||
if (result.error) return res.status(500).json({ error: result.error });
|
||||
res.json(result);
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Öffentliche Teilen-Links (kein Login nötig) ─────────────────────────────
|
||||
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
|
||||
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
|
||||
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
|
||||
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
|
||||
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
|
||||
//
|
||||
// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit
|
||||
// in den Logs nachvollziehbar ist, WER welchen Link benutzt hat.
|
||||
function getShareLinkByToken(token) {
|
||||
return db.prepare('SELECT id, name FROM koepi_share_links WHERE token=?').get(token) || null;
|
||||
}
|
||||
|
||||
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
|
||||
// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig
|
||||
const publicRateLimits = new Map();
|
||||
function publicRateLimit(req, res, next) {
|
||||
const ip = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const windowMs = 60000, maxReq = 30;
|
||||
const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs);
|
||||
if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' });
|
||||
arr.push(now);
|
||||
publicRateLimits.set(ip, arr);
|
||||
next();
|
||||
}
|
||||
// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, arr] of publicRateLimits) {
|
||||
const fresh = arr.filter(t => now - t < 60000);
|
||||
if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// GET /share-links – alle benannten Links auflisten (Admin)
|
||||
router.get('/share-links', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all()
|
||||
.map(l => ({ ...l, url: `${req.protocol}://${req.get('host')}/kp/${l.token}` }));
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
// POST /share-links – neuen benannten Link erstellen (Admin)
|
||||
router.post('/share-links', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const name = (req.body?.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'Name fehlt' });
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name);
|
||||
const link = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links WHERE token=?').get(token);
|
||||
res.json({ ...link, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
||||
});
|
||||
|
||||
// POST /share-links/:id/reset – Token dieses Links neu erzeugen (Name bleibt,
|
||||
// alter Link wird ungültig)
|
||||
router.post('/share-links/:id/reset', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const existing = db.prepare('SELECT * FROM koepi_share_links WHERE id=?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
db.prepare('UPDATE koepi_share_links SET token=? WHERE id=?').run(token, req.params.id);
|
||||
res.json({ id: existing.id, token, name: existing.name, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
||||
});
|
||||
|
||||
// DELETE /share-links/:id – Link löschen (Admin)
|
||||
router.delete('/share-links/:id', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM koepi_share_links WHERE id=?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /public/:token/manifest.json – eigenes PWA-Manifest für die öffentliche
|
||||
// Prospekt-Seite: eigener Name ("Dickens Prospekte") und eigener start_url,
|
||||
// damit die installierte App direkt wieder auf der öffentlichen Seite landet
|
||||
// statt auf dem normalen Login der Haupt-App.
|
||||
router.get('/public/:token/manifest.json', (req, res) => {
|
||||
const link = getShareLinkByToken(req.params.token);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.json({
|
||||
name: 'Dickens Prospekte',
|
||||
short_name: 'Prospekte',
|
||||
description: 'Wöchentliche Prospekte',
|
||||
start_url: `/kp/${req.params.token}`,
|
||||
scope: `/kp/${req.params.token}`,
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#0f1117',
|
||||
theme_color: '#0f1117',
|
||||
icons: [
|
||||
{ src: '/favicon.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any maskable' },
|
||||
{ src: '/icon-192.png', type: 'image/png', sizes: '192x192' },
|
||||
{ src: '/icon-512.png', type: 'image/png', sizes: '512x512' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// GET /public/:token/offers – öffentlich, nur Cache, kein Login
|
||||
router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
|
||||
const link = getShareLinkByToken(req.params.token);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
try {
|
||||
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
|
||||
res.json(result);
|
||||
} catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); }
|
||||
});
|
||||
|
||||
// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login
|
||||
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
|
||||
const link = getShareLinkByToken(req.params.token);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'], linkName: link.name });
|
||||
try {
|
||||
const result = await scrapeProspekte();
|
||||
res.json(result);
|
||||
} catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.runDailyCheck = runDailyCheck;
|
||||
module.exports.refreshOffersOnly = refreshOffersOnly;
|
||||
module.exports.resolveLocalStores = resolveLocalStores;
|
||||
212
backend/src/tools/linkliste/routes.js
Normal file
212
backend/src/tools/linkliste/routes.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Ordner ────────────────────────────────────────────────────────────────────
|
||||
router.get('/folders', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/folders', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { name, icon='📁' } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list_folders WHERE user_id=?').get(uid);
|
||||
const r = db.prepare(`INSERT INTO link_list_folders (user_id,name,icon,sort_order,created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(uid, name.trim(), icon, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/folders/:id', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { name=f.name, icon=f.icon, in_quickaccess } = req.body;
|
||||
const qa = in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : f.in_quickaccess;
|
||||
db.prepare('UPDATE link_list_folders SET name=?, icon=?, in_quickaccess=? WHERE id=?').run(name, icon, qa, f.id);
|
||||
res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(f.id));
|
||||
});
|
||||
|
||||
router.delete('/folders/:id', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
// Links aus Ordner herauslösen
|
||||
db.prepare('UPDATE link_list SET folder_id=NULL WHERE folder_id=? AND user_id=?').run(f.id, req.user.id);
|
||||
db.prepare('DELETE FROM link_list_folders WHERE id=?').run(f.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Ordner-Inhalt abrufen (für Folder-Modal im Dashboard)
|
||||
router.get('/folders/:id/links', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const links = db.prepare('SELECT * FROM link_list WHERE folder_id=? AND user_id=? ORDER BY sort_order,id').all(f.id, req.user.id);
|
||||
res.json({ folder: f, links });
|
||||
});
|
||||
|
||||
// ── Sortierung ────────────────────────────────────────────────────────────────
|
||||
router.put('/sort', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { links=[], folders=[] } = req.body;
|
||||
const upLink = db.prepare('UPDATE link_list SET sort_order=? WHERE id=? AND user_id=?');
|
||||
const upFolder = db.prepare('UPDATE link_list_folders SET sort_order=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => {
|
||||
links.forEach((id, i) => upLink.run(i, id, uid));
|
||||
folders.forEach((id, i) => upFolder.run(i, id, uid));
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Eigene Links ──────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const own = db.prepare(`
|
||||
SELECT l.*, 0 as is_shared, NULL as owner_name,
|
||||
(SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count
|
||||
FROM link_list l WHERE l.user_id=? ORDER BY l.folder_id IS NULL DESC, l.folder_id, l.sort_order, l.id
|
||||
`).all(uid);
|
||||
|
||||
const folders = db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(uid);
|
||||
|
||||
const shared = db.prepare(`
|
||||
SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count
|
||||
FROM link_list l
|
||||
JOIN link_list_shares s ON s.link_id=l.id
|
||||
JOIN users u ON u.id=l.user_id
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY l.created_at DESC
|
||||
`).all(uid);
|
||||
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT l.*, 0 as is_shared, u.username as shared_with_name
|
||||
FROM link_list l
|
||||
JOIN link_list_shares s ON s.link_id=l.id
|
||||
JOIN users u ON u.id=s.shared_with
|
||||
WHERE l.user_id=?
|
||||
ORDER BY l.title ASC
|
||||
`).all(uid);
|
||||
|
||||
// Ordner die mit mir geteilt wurden (inkl. deren Links)
|
||||
const sharedFolders = db.prepare(`
|
||||
SELECT f.*, u.username as owner_name
|
||||
FROM link_list_folders f
|
||||
JOIN link_folder_shares s ON s.folder_id=f.id
|
||||
JOIN users u ON u.id=f.user_id
|
||||
WHERE s.shared_with=?
|
||||
ORDER BY f.name ASC
|
||||
`).all(uid);
|
||||
|
||||
const sharedFolderLinks = sharedFolders.map(folder => ({
|
||||
...folder,
|
||||
links: db.prepare('SELECT * FROM link_list WHERE folder_id=? ORDER BY sort_order,id').all(folder.id)
|
||||
}));
|
||||
|
||||
// Ordner die ich geteilt habe
|
||||
const sharedFoldersByMe = db.prepare(`
|
||||
SELECT f.*, u.username as shared_with_name
|
||||
FROM link_list_folders f
|
||||
JOIN link_folder_shares s ON s.folder_id=f.id
|
||||
JOIN users u ON u.id=s.shared_with
|
||||
WHERE f.user_id=?
|
||||
ORDER BY f.name ASC
|
||||
`).all(uid);
|
||||
|
||||
res.json({ own, folders, shared, sharedByMe, sharedFolders: sharedFolderLinks, sharedFoldersByMe });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { title, url, icon = '🔗', description = '', folder_id = null } = req.body;
|
||||
if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM link_list WHERE user_id=?').get(uid);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(uid, title.trim(), url.trim(), icon, description.trim(), folder_id || null, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, url, icon, description, folder_id, in_quickaccess } = req.body;
|
||||
db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=?, folder_id=?, in_quickaccess=? WHERE id=?')
|
||||
.run(
|
||||
title ?? link.title,
|
||||
url ?? link.url,
|
||||
icon ?? link.icon,
|
||||
description ?? link.description,
|
||||
folder_id !== undefined ? (folder_id || null) : link.folder_id,
|
||||
in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : link.in_quickaccess,
|
||||
link.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM link_list WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Shares ────────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at
|
||||
FROM link_list_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.link_id=?
|
||||
`).all(link.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO link_list_shares (link_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(link.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM link_list_shares WHERE link_id=? AND shared_with=?').run(link.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Ordner-Shares ─────────────────────────────────────────────────────────────
|
||||
router.get('/folders/:id/shares', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const shares = db.prepare(`
|
||||
SELECT u.id, u.username, s.created_at as shared_at
|
||||
FROM link_folder_shares s JOIN users u ON u.id=s.shared_with
|
||||
WHERE s.folder_id=?
|
||||
`).all(f.id);
|
||||
res.json(shares);
|
||||
});
|
||||
|
||||
router.post('/folders/:id/share', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO link_folder_shares (folder_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(f.id, req.user.id, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/folders/:id/share/:userId', authenticate, (req, res) => {
|
||||
const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!f) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM link_folder_shares WHERE folder_id=? AND shared_with=?').run(f.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
1265
backend/src/tools/media/routes.js
Normal file
1265
backend/src/tools/media/routes.js
Normal file
File diff suppressed because it is too large
Load Diff
216
backend/src/tools/nachrichten/routes.js
Normal file
216
backend/src/tools/nachrichten/routes.js
Normal file
@@ -0,0 +1,216 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { logPush } = require('../../pushLog');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── Pushover Helper ───────────────────────────────────────────────────────────
|
||||
async function sendPushover(userKey, appToken, title, message, opts = {}, userId = null) {
|
||||
let priority = 0;
|
||||
try {
|
||||
const params = { token: appToken, user: userKey, title, message };
|
||||
if (opts.retry && opts.expire) {
|
||||
params.priority = 2;
|
||||
priority = 2;
|
||||
params.retry = opts.retry;
|
||||
params.expire = opts.expire;
|
||||
}
|
||||
await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
logPush({ userId, title, message, priority, source: 'nachrichten' });
|
||||
} catch {
|
||||
logPush({ userId, title, message, priority, source: 'nachrichten', success: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public Keys ───────────────────────────────────────────────────────────────
|
||||
router.get('/keys/:userId', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT public_key FROM user_keys WHERE user_id = ?').get(req.params.userId);
|
||||
res.json({ public_key: row?.public_key || null });
|
||||
});
|
||||
|
||||
router.post('/keys', authenticate, (req, res) => {
|
||||
const { public_key } = req.body;
|
||||
if (!public_key) return res.status(400).json({ error: 'Kein Schlüssel' });
|
||||
db.prepare(`
|
||||
INSERT INTO user_keys (user_id, public_key, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET public_key=excluded.public_key, updated_at=CURRENT_TIMESTAMP
|
||||
`).run(req.user.id, public_key);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
// Presence: User ist gerade aktiv im Chat
|
||||
router.post('/presence', authenticate, (req, res) => {
|
||||
const { active } = req.body;
|
||||
if (active) {
|
||||
db.prepare("UPDATE users SET chat_active_at=datetime('now','localtime') WHERE id=?").run(req.user.id);
|
||||
} else {
|
||||
db.prepare("UPDATE users SET chat_active_at=NULL WHERE id=?").run(req.user.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Admins sehen alle. Normale User sehen:
|
||||
// - keine hidden User
|
||||
// - wenn sie selbst hidden sind: nur Admins
|
||||
const meUser = db.prepare('SELECT hidden FROM users WHERE id=?').get(me);
|
||||
const isMeHidden = !!meUser?.hidden;
|
||||
|
||||
let whereExtra = '';
|
||||
if (!isAdmin && isMeHidden) {
|
||||
// Ich bin hidden → sehe nur Admins
|
||||
whereExtra = "AND u.role = 'admin'";
|
||||
} else if (!isAdmin) {
|
||||
// Normaler User → sieht keine hidden User
|
||||
whereExtra = "AND (u.hidden = 0 OR u.hidden IS NULL)";
|
||||
}
|
||||
// Admin → kein Filter
|
||||
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, uk.public_key,
|
||||
(SELECT COUNT(*) FROM messages
|
||||
WHERE sender_id = u.id AND recipient_id = ? AND read_by_recipient = 0) AS unread,
|
||||
(SELECT MAX(created_at) FROM messages
|
||||
WHERE (sender_id = u.id AND recipient_id = ?)
|
||||
OR (sender_id = ? AND recipient_id = u.id)) AS last_message_at
|
||||
FROM users u
|
||||
LEFT JOIN user_keys uk ON uk.user_id = u.id
|
||||
WHERE u.id != ? ${whereExtra}
|
||||
ORDER BY last_message_at DESC, u.username ASC
|
||||
`).all(me, me, me, me);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ── Unread Count ──────────────────────────────────────────────────────────────
|
||||
router.get('/unread', authenticate, (req, res) => {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) AS count FROM messages WHERE recipient_id = ? AND read_by_recipient = 0'
|
||||
).get(req.user.id);
|
||||
res.json({ count: row.count });
|
||||
});
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
router.get('/messages/:userId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const other = parseInt(req.params.userId);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE messages SET read_by_recipient = 1
|
||||
WHERE recipient_id = ? AND sender_id = ? AND read_by_recipient = 0
|
||||
`).run(me, other);
|
||||
|
||||
const msgs = db.prepare(`
|
||||
SELECT id, sender_id, encrypted_content, iv, created_at,
|
||||
(sender_id = ?) AS is_mine,
|
||||
read_by_recipient
|
||||
FROM messages
|
||||
WHERE (sender_id = ? AND recipient_id = ?)
|
||||
OR (recipient_id = ? AND sender_id = ?)
|
||||
ORDER BY created_at ASC
|
||||
`).all(me, me, other, me, other);
|
||||
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
router.post('/messages/:userId', authenticate, async (req, res) => {
|
||||
const me = req.user.id;
|
||||
const recipId = parseInt(req.params.userId);
|
||||
const { encrypted_content, iv } = req.body;
|
||||
if (!encrypted_content || !iv) return res.status(400).json({ error: 'Fehlende Felder' });
|
||||
|
||||
const recipient = db.prepare('SELECT id FROM users WHERE id = ?').get(recipId);
|
||||
if (!recipient) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
const result = db.prepare(
|
||||
"INSERT INTO messages (sender_id, recipient_id, encrypted_content, iv, created_at) VALUES (?, ?, ?, ?, datetime('now', 'localtime'))"
|
||||
).run(me, recipId, encrypted_content, iv);
|
||||
|
||||
const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand';
|
||||
const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId);
|
||||
if (pushover) {
|
||||
// Kein Push wenn Empfänger gerade im Chat ist (aktiv in letzten 45 Sekunden)
|
||||
const presence = db.prepare(`
|
||||
SELECT chat_active_at FROM users WHERE id=? AND chat_active_at > datetime('now','localtime','-45 seconds')
|
||||
`).get(recipId);
|
||||
if (!presence) {
|
||||
await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`,
|
||||
{ retry: pushover.retry, expire: pushover.expire }, recipId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// DELETE einzelne Nachricht – hard delete, beide Seiten dürfen löschen
|
||||
router.delete('/messages/:messageId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const mid = parseInt(req.params.messageId);
|
||||
db.prepare(
|
||||
'DELETE FROM messages WHERE id = ? AND (sender_id = ? OR recipient_id = ?)'
|
||||
).run(mid, me, me);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE gesamte Konversation – hard delete
|
||||
router.delete('/conversation/:userId', authenticate, (req, res) => {
|
||||
const me = req.user.id;
|
||||
const other = parseInt(req.params.userId);
|
||||
db.prepare(`
|
||||
DELETE FROM messages
|
||||
WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)
|
||||
`).run(me, other, other, me);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE alle eigenen Nachrichten (für Schlüssel-Reset)
|
||||
router.delete('/my-messages', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?')
|
||||
.run(req.user.id, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Pushover Settings ─────────────────────────────────────────────────────────
|
||||
router.get('/pushover', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT user_key, app_token, retry, expire FROM pushover_settings WHERE user_id = ?').get(req.user.id);
|
||||
res.json(row || { user_key: '', app_token: '', retry: null, expire: null });
|
||||
});
|
||||
|
||||
router.post('/pushover', authenticate, (req, res) => {
|
||||
const { user_key, app_token, retry, expire } = req.body;
|
||||
if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' });
|
||||
db.prepare(`
|
||||
INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token,
|
||||
retry=excluded.retry, expire=excluded.expire
|
||||
`).run(req.user.id, user_key, app_token, retry || null, expire || null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/pushover', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM pushover_settings WHERE user_id = ?').run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/pushover/test', authenticate, async (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id);
|
||||
if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' });
|
||||
const useEmergency = req.body?.emergency === true && row.retry && row.expire;
|
||||
const msg = useEmergency
|
||||
? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.`
|
||||
: 'Pushover-Verbindung erfolgreich! 🎉';
|
||||
await sendPushover(row.user_key, row.app_token, 'DickenDock', msg,
|
||||
useEmergency ? { retry: row.retry, expire: row.expire } : {}, req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
5
backend/src/tools/paywallkiller/routes.js
Normal file
5
backend/src/tools/paywallkiller/routes.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
// Paywall-Killer: gesamter Flow läuft im Browser des Users.
|
||||
// Kein aktiver Backend-Endpoint nötig.
|
||||
module.exports = router;
|
||||
41
backend/src/tools/qrcodes/routes.js
Normal file
41
backend/src/tools/qrcodes/routes.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE user_id=? ORDER BY created_at DESC').all(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const { label='', url, size=256, fg_color='#000000', bg_color='#ffffff', margin=4,
|
||||
dot_style='square', corner_style='square', caption='', caption_pos='bottom',
|
||||
caption_color='#000000', caption_size=14 } = req.body;
|
||||
if (!url?.trim()) return res.status(400).json({ error: 'URL erforderlich' });
|
||||
const r = db.prepare(`
|
||||
INSERT INTO qr_codes (user_id,label,url,size,fg_color,bg_color,margin,dot_style,corner_style,caption,caption_pos,caption_color,caption_size,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`).run(uid, label.trim(), url.trim(), size, fg_color, bg_color, margin, dot_style, corner_style, caption.trim(), caption_pos, caption_color, caption_size);
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const qr = db.prepare('SELECT * FROM qr_codes WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||
if (!qr) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const fields = ['label','url','size','fg_color','bg_color','margin','dot_style','corner_style','caption','caption_pos','caption_color','caption_size'];
|
||||
const updates = {};
|
||||
fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; });
|
||||
if (!Object.keys(updates).length) return res.json(qr);
|
||||
const sets = Object.keys(updates).map(f=>`${f}=?`).join(',');
|
||||
db.prepare(`UPDATE qr_codes SET ${sets} WHERE id=?`).run(...Object.values(updates), qr.id);
|
||||
res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(qr.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM qr_codes WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
609
backend/src/tools/schocken/routes.js
Normal file
609
backend/src/tools/schocken/routes.js
Normal file
@@ -0,0 +1,609 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { logPush } = require('../../pushLog');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schocken_games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
players TEXT NOT NULL, -- JSON: [{id, username, order}]
|
||||
status TEXT NOT NULL DEFAULT 'lobby',
|
||||
-- status: lobby | half1 | half2 | endkampf | finished
|
||||
phase_status TEXT NOT NULL DEFAULT 'playing',
|
||||
-- phase_status: playing | finished
|
||||
stock INTEGER NOT NULL DEFAULT 15, -- Scheiben auf dem Stock
|
||||
player_chips TEXT NOT NULL DEFAULT '{}', -- JSON: {userId: chipCount}
|
||||
has_16th INTEGER, -- userId der die 16. Scheibe hat
|
||||
loser_h1 INTEGER, -- userId Verlierer Hälfte 1
|
||||
loser_h2 INTEGER, -- userId Verlierer Hälfte 2
|
||||
current_round TEXT, -- JSON: aktueller Rundenstand
|
||||
round_number INTEGER NOT NULL DEFAULT 1,
|
||||
beginner_id INTEGER, -- wer fängt die Runde an
|
||||
current_player_idx INTEGER NOT NULL DEFAULT 0,
|
||||
max_rolls INTEGER, -- vom Beginner bestimmt
|
||||
first_round INTEGER NOT NULL DEFAULT 1, -- 1 = erste Runde der Hälfte
|
||||
drink_losses TEXT NOT NULL DEFAULT '{}', -- {userId: count}
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
`);
|
||||
})();
|
||||
|
||||
// ── Würfellogik ───────────────────────────────────────────────────────────────
|
||||
function rollDice(count = 3) {
|
||||
return Array.from({ length: count }, () => Math.floor(Math.random() * 6) + 1);
|
||||
}
|
||||
|
||||
// Würfel klassifizieren
|
||||
function classifyDice(dice) {
|
||||
const sorted = [...dice].sort((a,b) => a-b);
|
||||
const [a,b,c] = sorted;
|
||||
|
||||
// Schock aus: 1-1-1
|
||||
if (a===1 && b===1 && c===1) return { type:'schock_aus', scheiben: 0, label:'Schock aus! 💀' };
|
||||
|
||||
// Julchen: 4-2-1 (unsortiert erkannt)
|
||||
if (dice.includes(1) && dice.includes(2) && dice.includes(4)) return { type:'julchen', scheiben:7, label:'Julchen! 🎉' };
|
||||
|
||||
// General: 3x gleich (außer 3x1)
|
||||
if (a===b && b===c) return { type:'general', scheiben:3, label:`General ${a}er! 🎲`, value: a };
|
||||
|
||||
// Schock: 2x Eins + andere
|
||||
if (a===1 && b===1) {
|
||||
const schockVal = c;
|
||||
const label = schockVal === 2 ? 'Schock doof 😅' : `Schock ${schockVal}!`;
|
||||
return { type:'schock', scheiben: schockVal, label, value: schockVal };
|
||||
}
|
||||
|
||||
// Straße: aufeinanderfolgende Zahlen (nur wenn alle 3 auf einmal gewürfelt)
|
||||
if (c-a===2 && b-a===1) return { type:'strasse', scheiben:2, label:`Straße ${a}-${b}-${c}!`, value: a };
|
||||
|
||||
// Normale Zahl
|
||||
// Normale Zahlen: absteigend sortiert als dreistellige Zahl (z.B. 6-2-1 = 621, 5-5-4 = 554)
|
||||
const desc = [...sorted].reverse(); // absteigend
|
||||
const normalValue = desc[0]*100 + desc[1]*10 + desc[2];
|
||||
return { type:'normal', scheiben:1, label:`${desc[0]}-${desc[1]}-${desc[2]}`, value: normalValue };
|
||||
}
|
||||
|
||||
// Wertungsvergleich: wer hat schlechtere Würfel (bekommt Scheiben)
|
||||
// Gibt -1 wenn a schlechter, 1 wenn b schlechter, 0 bei echtem Gleichstand
|
||||
const TYPE_ORDER = ['normal','strasse','general','schock','julchen','schock_aus'];
|
||||
function compareResults(a, b) {
|
||||
const ai = TYPE_ORDER.indexOf(a.result.type);
|
||||
const bi = TYPE_ORDER.indexOf(b.result.type);
|
||||
if (ai !== bi) return ai < bi ? -1 : 1; // niedrigerer Typ = schlechter
|
||||
|
||||
// Gleicher Typ — Detailvergleich
|
||||
if (a.result.type === 'normal') {
|
||||
// Niedrigere Augensumme = schlechter
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'strasse') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'schock') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
if (a.result.type === 'general') {
|
||||
if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sendPush(userId, title, message) {
|
||||
const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||
if (!cfg?.app_token || !cfg?.user_key) return;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
|
||||
}).catch(() => {});
|
||||
logPush({ userId, title, message, priority: 0, source: 'schocken' });
|
||||
}
|
||||
|
||||
const uid = req => {
|
||||
// Testmodus: Admin kann als anderen User agieren
|
||||
const testAs = req.headers['x-schocken-test-as'];
|
||||
if (testAs && req.user?.role === 'admin') {
|
||||
const testId = parseInt(testAs);
|
||||
if (!isNaN(testId)) return testId;
|
||||
}
|
||||
return req.user.id;
|
||||
};
|
||||
|
||||
// ── Alle Spiele des Users ─────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const games = db.prepare(`
|
||||
SELECT * FROM schocken_games
|
||||
WHERE players LIKE ? ORDER BY updated_at DESC LIMIT 20
|
||||
`).all(`%"id":${me}%`);
|
||||
res.json(games.map(g => ({
|
||||
...g,
|
||||
players: JSON.parse(g.players),
|
||||
player_chips: JSON.parse(g.player_chips),
|
||||
drink_losses: JSON.parse(g.drink_losses),
|
||||
current_round: g.current_round ? JSON.parse(g.current_round) : null,
|
||||
})));
|
||||
});
|
||||
|
||||
// ── Users für Einladen ────────────────────────────────────────────────────────
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
const users = isAdmin
|
||||
? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me)
|
||||
: db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ── Einzelnes Spiel ───────────────────────────────────────────────────────────
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
if (!players.find(p => p.id === me)) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json({
|
||||
...game,
|
||||
players,
|
||||
player_chips: JSON.parse(game.player_chips),
|
||||
drink_losses: JSON.parse(game.drink_losses),
|
||||
current_round: game.current_round ? JSON.parse(game.current_round) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Neues Spiel erstellen ─────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { player_ids = [] } = req.body;
|
||||
if (player_ids.length < 1) return res.status(400).json({ error: 'Mindestens 1 weiterer Spieler' });
|
||||
|
||||
const allIds = [me, ...player_ids.filter(id => id !== me)];
|
||||
const userRows = db.prepare(`SELECT id, username FROM users WHERE id IN (${allIds.map(()=>'?').join(',')}) ORDER BY username`).all(...allIds);
|
||||
|
||||
// Reihenfolge: Ersteller zuerst, dann die anderen
|
||||
const ordered = [
|
||||
userRows.find(u => u.id === me),
|
||||
...allIds.slice(1).map(id => userRows.find(u => u.id === id)).filter(Boolean),
|
||||
];
|
||||
const players = ordered.map((u, i) => ({ id: u.id, username: u.username, order: i }));
|
||||
const playerChips = Object.fromEntries(players.map(p => [p.id, 0]));
|
||||
const drinkLosses = Object.fromEntries(players.map(p => [p.id, 0]));
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO schocken_games (players, stock, player_chips, drink_losses, beginner_id, status)
|
||||
VALUES (?, 15, ?, ?, ?, 'lobby')
|
||||
`).run(JSON.stringify(players), JSON.stringify(playerChips), JSON.stringify(drinkLosses), me);
|
||||
|
||||
// Pushover an andere Spieler
|
||||
const myName = ordered[0].username;
|
||||
for (const p of players.slice(1)) {
|
||||
sendPush(p.id, '🎲 Schocken', `${myName} lädt dich zu einer Runde Schocken ein!`);
|
||||
}
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// ── Spiel starten (Lobby → Hälfte 1) ─────────────────────────────────────────
|
||||
router.post('/:id/start', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
if (players[0].id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann starten' });
|
||||
if (game.status !== 'lobby') return res.status(400).json({ error: 'Spiel läuft schon' });
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET status='half1', first_round=1, beginner_id=?,
|
||||
current_player_idx=0, updated_at=datetime('now','localtime') WHERE id=?`).run(me, game.id);
|
||||
|
||||
for (const p of players.slice(1)) {
|
||||
sendPush(p.id, '🎲 Schocken', 'Das Spiel beginnt! Du bist dran.');
|
||||
}
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Würfeln ───────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/roll', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { keep_dice = [], flip_sixes = false, go_dark = false } = req.body;
|
||||
// keep_dice: array of indices (0-2) der Würfel die stehen bleiben
|
||||
// flip_sixes: zwei Sechsen zu Eins umdrehen
|
||||
// go_dark: dunkel legen nach diesem Wurf
|
||||
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
|
||||
|
||||
if (!currentPlayer || currentPlayer.id !== me)
|
||||
return res.status(400).json({ error: 'Nicht dein Zug' });
|
||||
|
||||
let round = game.current_round ? JSON.parse(game.current_round) : initRound(game, activePlayers);
|
||||
const myState = round.playerStates[me];
|
||||
|
||||
if (myState.done) return res.status(400).json({ error: 'Du hast schon gewürfelt' });
|
||||
if (myState.dark) return res.status(400).json({ error: 'Du bist dunkel' });
|
||||
|
||||
// Flip sixes: nur wenn noch Würfe übrig und nicht letzter Wurf
|
||||
if (flip_sixes) {
|
||||
const sixes = myState.dice.reduce((acc, v, i) => v === 6 ? [...acc, i] : acc, []);
|
||||
if (sixes.length >= 2) {
|
||||
myState.dice[sixes[0]] = 1;
|
||||
myState.dice[sixes[1]] = 1;
|
||||
// Kostet keinen Wurf, braucht aber noch einen
|
||||
}
|
||||
}
|
||||
|
||||
// Würfeln
|
||||
const rollCount = myState.roll_count;
|
||||
const isFirstRound = !!game.first_round;
|
||||
|
||||
// Neue Würfel für nicht-stehen-gelassene
|
||||
if (!isFirstRound || rollCount === 0) {
|
||||
const newDice = rollDice(3 - keep_dice.length);
|
||||
let newIdx = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (!keep_dice.includes(i)) myState.dice[i] = newDice[newIdx++];
|
||||
}
|
||||
}
|
||||
|
||||
myState.roll_count++;
|
||||
|
||||
// Straße nur gültig wenn im ersten Wurf (keine stehen gelassenen Würfel)
|
||||
const classified = classifyDice(myState.dice);
|
||||
if (classified.type === 'strasse' && (keep_dice.length > 0 || myState.roll_count > 1)) {
|
||||
// Straße ungültig — als normal werten
|
||||
classified.type = 'normal';
|
||||
classified.scheiben = 1;
|
||||
classified.label = 'Normal (Straße ungültig)';
|
||||
}
|
||||
|
||||
// Max rolls durch Beginner bestimmt
|
||||
const maxRolls = round.max_rolls || 3;
|
||||
const isDark = go_dark || myState.roll_count >= maxRolls || isFirstRound;
|
||||
const isDone = isDark || myState.roll_count >= maxRolls || isFirstRound;
|
||||
|
||||
myState.dark = go_dark || (myState.roll_count >= maxRolls && !isDone) || isFirstRound;
|
||||
myState.done = isDone;
|
||||
myState.result = isDone && !go_dark ? classified : null; // bei dunkel: result erst am Ende
|
||||
|
||||
// Wenn Beginner (erster Spieler): max_rolls setzen
|
||||
if (currentPlayer.id === round.beginner_id && round.max_rolls === null) {
|
||||
round.max_rolls = myState.roll_count;
|
||||
}
|
||||
|
||||
// Nächsten Spieler
|
||||
const nextIdx = (game.current_player_idx + 1) % activePlayers.length;
|
||||
const allDone = activePlayers.every(p => round.playerStates[p.id]?.done);
|
||||
|
||||
round.playerStates[me] = myState;
|
||||
|
||||
if (allDone) {
|
||||
// Alle dunkel aufdecken
|
||||
for (const p of activePlayers) {
|
||||
const ps = round.playerStates[p.id];
|
||||
if (!ps.result) {
|
||||
ps.result = classifyDice(ps.dice);
|
||||
// Straße bei dunkel-legen auch ungültig wenn keep_dice vorhanden (nicht trackbar nachträglich)
|
||||
}
|
||||
}
|
||||
round.phase = 'reveal';
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET current_round=?, current_player_idx=?,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(round), allDone ? game.current_player_idx : nextIdx, game.id);
|
||||
|
||||
// Pushover an nächsten Spieler
|
||||
if (!allDone) {
|
||||
const next = activePlayers[nextIdx];
|
||||
if (next && next.id !== me) {
|
||||
sendPush(next.id, '🎲 Schocken', `${currentPlayer.username} hat gewürfelt — du bist dran!`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Runde auswerten ───────────────────────────────────────────────────────────
|
||||
router.post('/:id/evaluate', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
if (players[0].id !== me && activePlayers[0]?.id !== me)
|
||||
return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
|
||||
const round = JSON.parse(game.current_round);
|
||||
if (round.phase !== 'reveal') return res.status(400).json({ error: 'Noch nicht alle fertig' });
|
||||
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
let stock = game.stock;
|
||||
|
||||
// Schock aus prüfen
|
||||
const schockAus = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
|
||||
if (schockAus) {
|
||||
return handleSchockAus(game, round, activePlayers, res, me);
|
||||
}
|
||||
|
||||
// Bestes Ergebnis finden (höchster Typ)
|
||||
const results = activePlayers.map(p => ({
|
||||
player: p,
|
||||
state: round.playerStates[p.id],
|
||||
result: round.playerStates[p.id]?.result,
|
||||
rolls: round.playerStates[p.id]?.roll_count || 1,
|
||||
})).filter(r => r.result);
|
||||
|
||||
// Schlechtestes Ergebnis = bekommt Scheiben
|
||||
results.sort((a, b) => {
|
||||
const cmp = compareResults(a, b);
|
||||
if (cmp !== 0) return cmp; // -1 wenn a schlechter
|
||||
// Gleicher Typ: mehr Würfe = schlechter (vorne in der Liste = Index 0 = Verlierer)
|
||||
if (a.rolls !== b.rolls) return a.rolls - b.rolls; // mehr Würfe = schlechter (vorne)
|
||||
// Nachleger: später in Reihenfolge = schlechter (vorne)
|
||||
return activePlayers.indexOf(a.player) - activePlayers.indexOf(b.player);
|
||||
});
|
||||
|
||||
const loser = results[0]; // schlechtestes Ergebnis (Index 0 nach sort)
|
||||
const winner = results[results.length - 1]; // bestes Ergebnis
|
||||
// Scheiben die verteilt werden = Wert des BESTEN Ergebnisses der Runde
|
||||
const scheiben = winner.result.scheiben;
|
||||
|
||||
// Scheiben verteilen
|
||||
let fromStock = 0;
|
||||
let newStock = stock;
|
||||
if (stock >= scheiben) {
|
||||
fromStock = scheiben;
|
||||
newStock = stock - scheiben;
|
||||
} else {
|
||||
fromStock = stock;
|
||||
newStock = 0;
|
||||
}
|
||||
|
||||
const fromOthers = scheiben - fromStock;
|
||||
playerChips[loser.player.id] = (playerChips[loser.player.id] || 0) + scheiben;
|
||||
|
||||
// Wenn vom Stock nicht genug: Rest von aktivsten Spielern nehmen (noch nicht implementiert vereinfacht)
|
||||
if (fromOthers > 0) {
|
||||
// Scheiben von anderen Spielern umverteilen (von denen mit meisten Scheiben)
|
||||
const donors = activePlayers
|
||||
.filter(p => p.id !== loser.player.id && playerChips[p.id] > 0)
|
||||
.sort((a,b) => playerChips[b.id] - playerChips[a.id]);
|
||||
let remaining = fromOthers;
|
||||
for (const donor of donors) {
|
||||
if (remaining <= 0) break;
|
||||
const take = Math.min(playerChips[donor.id], remaining);
|
||||
playerChips[donor.id] -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
|
||||
// Hälfte/Endkampf vorbei? Prüfen ob Stock leer und nur einer Scheiben hat
|
||||
const playersWithChips = activePlayers.filter(p => playerChips[p.id] > 0);
|
||||
const phaseOver = newStock === 0 && playersWithChips.length <= 1;
|
||||
|
||||
let newStatus = game.status;
|
||||
let newPhaseStatus = 'playing';
|
||||
let loserH1 = game.loser_h1;
|
||||
let loserH2 = game.loser_h2;
|
||||
let has16th = game.has_16th;
|
||||
let eventMsg = null;
|
||||
|
||||
if (phaseOver) {
|
||||
const phaseLoser = playersWithChips[0] || loser.player;
|
||||
|
||||
if (game.status === 'half1') {
|
||||
// Verlierer H1 bestimmt
|
||||
loserH1 = phaseLoser.id;
|
||||
has16th = phaseLoser.id;
|
||||
newStatus = 'half2';
|
||||
// Chips reset, Stock reset
|
||||
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
|
||||
newStock = 15;
|
||||
eventMsg = `${phaseLoser.username} hat die erste Hälfte verloren. Die erste Hälfte kostet nur Nerven! 😅`;
|
||||
// Pushover
|
||||
for (const p of players) {
|
||||
sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
}
|
||||
} else if (game.status === 'half2') {
|
||||
loserH2 = phaseLoser.id;
|
||||
if (loserH1 === loserH2) {
|
||||
// Doppelfeige!
|
||||
newStatus = 'finished';
|
||||
newPhaseStatus = 'finished';
|
||||
const dl = JSON.parse(game.drink_losses);
|
||||
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
|
||||
eventMsg = `${phaseLoser.username} ist DOPPELFEIGE! 🪶 Die Runde ist vorbei.`;
|
||||
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, has_16th=?,
|
||||
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newStatus, newPhaseStatus, loserH2, has16th, JSON.stringify(playerChips),
|
||||
newStock, dl, game.id);
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
return res.json(getGame(game.id, me));
|
||||
}
|
||||
// Endkampf
|
||||
newStatus = 'endkampf';
|
||||
Object.keys(playerChips).forEach(k => { playerChips[k] = 0; });
|
||||
newStock = 15;
|
||||
eventMsg = `${phaseLoser.username} verliert die zweite Hälfte! Endkampf: ${players.find(p=>p.id===loserH1)?.username} vs ${phaseLoser.username}`;
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
} else if (game.status === 'endkampf') {
|
||||
newStatus = 'finished';
|
||||
newPhaseStatus = 'finished';
|
||||
const dl = JSON.parse(game.drink_losses);
|
||||
dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1;
|
||||
eventMsg = `${phaseLoser.username} hat den Endkampf verloren und muss die nächste Runde zahlen! 🍺`;
|
||||
db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?,
|
||||
player_chips=?, stock=?, drink_losses=?, current_round=NULL,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newStatus, newPhaseStatus, loserH2, JSON.stringify(playerChips), newStock, JSON.stringify(dl), game.id);
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
return res.json(getGame(game.id, me));
|
||||
}
|
||||
}
|
||||
|
||||
// Nächste Runde vorbereiten
|
||||
const nextBeginner = loser.player;
|
||||
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=?, status=?, loser_h1=?, loser_h2=?,
|
||||
has_16th=?, beginner_id=?, current_round=NULL, current_player_idx=0, first_round=0,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(playerChips), newStock, newStatus, loserH1, loserH2,
|
||||
has16th, nextBeginner.id, game.id);
|
||||
|
||||
// Pushover an Verlierer der Runde (er fängt an)
|
||||
sendPush(loser.player.id, '🎲 Schocken', `Du hast die Runde verloren und fängst die nächste an! (${scheiben} Scheibe${scheiben!==1?'n':''})`);
|
||||
|
||||
res.json({ ...getGame(game.id, me), round_result: { loser: loser.player, scheiben, eventMsg } });
|
||||
});
|
||||
|
||||
// ── Becher umdrehen (Runde bereit signalisieren) ──────────────────────────────
|
||||
router.post('/:id/ready', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
const activePlayers = getActivePlayers(game);
|
||||
|
||||
// Spieler der eigentlich raus wäre aber trotzdem ready drückt → wird wieder aktiv (Falle!)
|
||||
const isActuallyActive = activePlayers.find(p => p.id === me);
|
||||
const isOut = !isActuallyActive && playerChips[me] === 0 && game.stock === 0;
|
||||
|
||||
let round = game.current_round ? JSON.parse(game.current_round) : null;
|
||||
if (!round) {
|
||||
round = initRound(game, isOut ? [...activePlayers, players.find(p=>p.id===me)] : activePlayers);
|
||||
}
|
||||
|
||||
round.ready = round.ready || {};
|
||||
round.ready[me] = true;
|
||||
|
||||
// Wenn isOut und drückt trotzdem: wird als "gefangen" markiert
|
||||
if (isOut) {
|
||||
round.trapped = round.trapped || [];
|
||||
if (!round.trapped.includes(me)) round.trapped.push(me);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE schocken_games SET current_round=?, updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(round), game.id);
|
||||
|
||||
res.json(getGame(game.id, me));
|
||||
});
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function getActivePlayers(game) {
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
const stock = game.stock;
|
||||
|
||||
if (game.status === 'endkampf') {
|
||||
return players.filter(p => p.id === game.loser_h1 || p.id === game.loser_h2);
|
||||
}
|
||||
|
||||
// Wenn Stock noch voll oder erste Runde: alle mitspielen
|
||||
if (stock > 0) return players;
|
||||
|
||||
// Stock leer: nur Spieler mit Scheiben
|
||||
return players.filter(p => playerChips[p.id] > 0);
|
||||
}
|
||||
|
||||
function initRound(game, activePlayers) {
|
||||
const playerStates = {};
|
||||
for (const p of activePlayers) {
|
||||
playerStates[p.id] = {
|
||||
dice: [null, null, null],
|
||||
roll_count: 0,
|
||||
done: false,
|
||||
dark: false,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
phase: 'rolling',
|
||||
beginner_id: game.beginner_id,
|
||||
max_rolls: game.first_round ? 1 : null,
|
||||
ready: {},
|
||||
trapped: [],
|
||||
playerStates,
|
||||
};
|
||||
}
|
||||
|
||||
function handleSchockAus(game, round, activePlayers, res, me) {
|
||||
const players = JSON.parse(game.players);
|
||||
const playerChips = JSON.parse(game.player_chips);
|
||||
|
||||
// Alle Scheiben (Stock + Spieler) → schlechtester Spieler
|
||||
const allChips = Object.values(playerChips).reduce((s,v) => s+v, 0) + game.stock;
|
||||
|
||||
// Wer hat die wenigsten Punkte (außer dem der Schock aus hat)?
|
||||
const schockAusPlayer = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus');
|
||||
const others = activePlayers.filter(p => p.id !== schockAusPlayer?.id);
|
||||
|
||||
// Vereinfacht: Schock aus Spieler hat gewonnen, alle Scheiben gehen an den schlechtesten der anderen
|
||||
// (Schock aus steht über allem)
|
||||
const newChips = Object.fromEntries(Object.keys(playerChips).map(k => [k, 0]));
|
||||
|
||||
// Verlierer = wer die wenigsten Punkte hat unter den anderen
|
||||
// Bei nur einem anderen: der verliert
|
||||
const phaseLoser = others.length > 0 ? others[others.length - 1] : schockAusPlayer;
|
||||
newChips[phaseLoser.id] = allChips;
|
||||
|
||||
let newStatus = game.status;
|
||||
let loserH1 = game.loser_h1;
|
||||
let has16th = game.has_16th;
|
||||
|
||||
if (game.status === 'half1') {
|
||||
loserH1 = phaseLoser.id;
|
||||
has16th = phaseLoser.id;
|
||||
newStatus = 'half2';
|
||||
Object.keys(newChips).forEach(k => { newChips[k] = 0; });
|
||||
const eventMsg = `SCHOCK AUS! ${phaseLoser.username} verliert die erste Hälfte! Die erste Hälfte kostet nur Nerven! 😅`;
|
||||
for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg);
|
||||
db.prepare(`UPDATE schocken_games SET player_chips=?, stock=15, status=?, loser_h1=?, has_16th=?,
|
||||
beginner_id=?, current_round=NULL, current_player_idx=0, first_round=1,
|
||||
updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(JSON.stringify(newChips), newStatus, loserH1, has16th, phaseLoser.id, game.id);
|
||||
}
|
||||
|
||||
res.json({ ...getGame(game.id, me), schock_aus: true, phase_loser: phaseLoser });
|
||||
}
|
||||
|
||||
function getGame(id, requesterId) {
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(id);
|
||||
if (!game) return null;
|
||||
return {
|
||||
...game,
|
||||
players: JSON.parse(game.players),
|
||||
player_chips: JSON.parse(game.player_chips),
|
||||
drink_losses: JSON.parse(game.drink_losses),
|
||||
current_round: game.current_round ? JSON.parse(game.current_round) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spiel abbrechen (Admin only) — wird komplett gelöscht ───────────────────
|
||||
router.post('/:id/cancel', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Nur Admin' });
|
||||
const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const players = JSON.parse(game.players);
|
||||
db.prepare('DELETE FROM schocken_games WHERE id=?').run(game.id);
|
||||
for (const p of players) {
|
||||
sendPush(p.id, '🎲 Schocken', 'Das Spiel wurde vom Admin abgebrochen.');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
142
backend/src/tools/snippets/routes.js
Normal file
142
backend/src/tools/snippets/routes.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
const getTags = id => db.prepare('SELECT tag FROM snippet_tags WHERE snippet_id=?').all(id).map(r=>r.tag);
|
||||
|
||||
const enrich = (s) => ({ ...s, tags: getTags(s.id) });
|
||||
|
||||
// ── Alle Snippets (eigene + geteilt) ────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const own = db.prepare(`
|
||||
SELECT s.*, u.username as owner
|
||||
FROM snippets s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.user_id=? ORDER BY s.updated_at DESC, s.created_at DESC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
const shared = db.prepare(`
|
||||
SELECT s.*, u.username as owner, 1 as is_shared
|
||||
FROM snippets s
|
||||
JOIN snippet_shares sh ON sh.snippet_id=s.id
|
||||
JOIN users u ON u.id=s.user_id
|
||||
WHERE sh.shared_with=?
|
||||
ORDER BY s.updated_at DESC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
const sharedByMe = db.prepare(`
|
||||
SELECT s.*, u.username as shared_with_name
|
||||
FROM snippets s
|
||||
JOIN snippet_shares sh ON sh.snippet_id=s.id
|
||||
JOIN users u ON u.id=sh.shared_with
|
||||
WHERE s.user_id=?
|
||||
ORDER BY s.title ASC
|
||||
`).all(me).map(enrich);
|
||||
|
||||
res.json({ own, shared, sharedByMe });
|
||||
});
|
||||
|
||||
// ── Einzelnes Snippet ────────────────────────────────────────────────────────
|
||||
router.get('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = s.user_id === uid(req);
|
||||
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json(enrich(s));
|
||||
});
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/history', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=?').get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isOwner = s.user_id === uid(req);
|
||||
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
|
||||
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
res.json(db.prepare('SELECT * FROM snippet_history WHERE snippet_id=? ORDER BY saved_at DESC').all(s.id));
|
||||
});
|
||||
|
||||
// ── Erstellen ────────────────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { title, code='', language='text', description='', tags=[] } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
const r = db.prepare(`
|
||||
INSERT INTO snippets (user_id, title, code, language, description, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))
|
||||
`).run(uid(req), title.trim(), code, language, description);
|
||||
const id = r.lastInsertRowid;
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
|
||||
db.transaction(()=>{ tags.forEach(t => insertTag.run(id, t)); })();
|
||||
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(id)));
|
||||
});
|
||||
|
||||
// ── Aktualisieren ────────────────────────────────────────────────────────────
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title, code, language, description, tags } = req.body;
|
||||
|
||||
// History-Eintrag wenn Code geändert
|
||||
if (code !== undefined && code !== s.code) {
|
||||
db.prepare(`INSERT INTO snippet_history (snippet_id, code, language, saved_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(s.id, s.code, s.language);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE snippets SET title=?,code=?,language=?,description=?,updated_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(title??s.title, code??s.code, language??s.language, description??s.description, s.id);
|
||||
|
||||
if (Array.isArray(tags)) {
|
||||
db.prepare('DELETE FROM snippet_tags WHERE snippet_id=?').run(s.id);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
|
||||
db.transaction(()=>{ tags.forEach(t => ins.run(s.id, t)); })();
|
||||
}
|
||||
|
||||
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(s.id)));
|
||||
});
|
||||
|
||||
// ── Löschen ──────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id/history/:hid', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM snippet_history WHERE id=? AND snippet_id=?').run(req.params.hid, s.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM snippets WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Shares ───────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/shares', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json(db.prepare(`
|
||||
SELECT u.id, u.username, sh.created_at as shared_at
|
||||
FROM snippet_shares sh JOIN users u ON u.id=sh.shared_with WHERE sh.snippet_id=?
|
||||
`).all(s.id));
|
||||
});
|
||||
|
||||
router.post('/:id/share', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
|
||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
if (target.id === uid(req)) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
|
||||
db.prepare(`INSERT OR IGNORE INTO snippet_shares (snippet_id,shared_by,shared_with,created_at) VALUES (?,?,?,datetime('now','localtime'))`)
|
||||
.run(s.id, uid(req), target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/share/:userId', authenticate, (req, res) => {
|
||||
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM snippet_shares WHERE snippet_id=? AND shared_with=?').run(s.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
266
backend/src/tools/statistik/routes.js
Normal file
266
backend/src/tools/statistik/routes.js
Normal file
@@ -0,0 +1,266 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../db');
|
||||
const { authenticate } = require('../../middleware/auth');
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Ausgaben CRUD ──────────────────────────────────────────────────────────
|
||||
router.get('/expenses', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
let q = 'SELECT * FROM expenses WHERE user_id=?';
|
||||
const p = [uid(req)];
|
||||
if (from) { q += ' AND date>=?'; p.push(from); }
|
||||
if (to) { q += ' AND date<=?'; p.push(to); }
|
||||
res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p));
|
||||
});
|
||||
|
||||
router.post('/expenses', authenticate, (req, res) => {
|
||||
const { date, category, description, amount } = req.body;
|
||||
if (!date || !description || !amount) return res.status(400).json({ error: 'Fehlende Felder' });
|
||||
const r = db.prepare('INSERT INTO expenses (user_id,date,category,description,amount) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), date, category||'Sonstiges', description, parseFloat(amount));
|
||||
res.json(db.prepare('SELECT * FROM expenses WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/expenses/:id', authenticate, (req, res) => {
|
||||
const e = db.prepare('SELECT id FROM expenses WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!e) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
db.prepare('DELETE FROM expenses WHERE id=?').run(e.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Revenue eines Auftrags ────────────────────────────────────────────────
|
||||
function getOrderRevenue(order) {
|
||||
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
|
||||
const r = db.prepare(
|
||||
'SELECT COALESCE(SUM(custom_price * stueckzahl), 0) as s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL'
|
||||
).get(order.id);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
function getOrderBaseCost(orderId) {
|
||||
const r = db.prepare(`
|
||||
SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) as s
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).get(orderId);
|
||||
return r?.s || 0;
|
||||
}
|
||||
|
||||
function round2(n) { return Math.round((n||0) * 100) / 100; }
|
||||
|
||||
// ── Statistik-Übersicht ────────────────────────────────────────────────────
|
||||
router.get('/overview', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
const u = uid(req);
|
||||
|
||||
// Datums-Vergleich: ISO-String slice(0,10) = YYYY-MM-DD
|
||||
const inRange = (dateStr) => {
|
||||
if (!dateStr) return !from && !to; // kein Datum + kein Filter = ja; kein Datum + Filter = nein
|
||||
const d = dateStr.slice(0, 10);
|
||||
if (from && d < from) return false;
|
||||
if (to && d > to) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Alle Aufträge
|
||||
const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u);
|
||||
|
||||
// Zeitraum-Filterung:
|
||||
// - Bezahlte Aufträge: Datum = bezahlt_am (wann wurde Geld erhalten)
|
||||
// - Offene Aufträge (noch nicht bezahlt): Datum = created_at (wann erstellt)
|
||||
const filteredOrders = allOrders.filter(o => {
|
||||
if (!from && !to) return true; // kein Filter → alle
|
||||
if (o.bezahlt) {
|
||||
// Bezahlt → nach bezahlt_am wenn vorhanden, sonst created_at
|
||||
// Wenn bezahlt_am NULL ist (alte Aufträge vor dem Feature): created_at nutzen
|
||||
const d = o.bezahlt_am || o.created_at;
|
||||
return inRange(d);
|
||||
} else {
|
||||
return inRange(o.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
const paidOrders = filteredOrders.filter(o => !!o.bezahlt);
|
||||
const openOrders = filteredOrders.filter(o => !o.bezahlt && o.status !== 'warteliste');
|
||||
|
||||
// KPI-Werte für den gewählten Zeitraum
|
||||
const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||
const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||
const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0);
|
||||
const totalProfit = totalRevenue - totalBaseCost;
|
||||
|
||||
// Ausgaben im Zeitraum
|
||||
let eq = 'SELECT * FROM expenses WHERE user_id=?';
|
||||
const ep = [u];
|
||||
if (from) { eq += ' AND date>=?'; ep.push(from); }
|
||||
if (to) { eq += ' AND date<=?'; ep.push(to); }
|
||||
const expenses = db.prepare(eq + ' ORDER BY date DESC').all(...ep);
|
||||
const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0);
|
||||
const netProfit = totalProfit - totalExpenses;
|
||||
|
||||
// Status-Verteilung im Zeitraum
|
||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 };
|
||||
for (const o of filteredOrders) {
|
||||
if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++;
|
||||
else if (!!o.bezahlt) byStatus.bezahlt++;
|
||||
else if (o.status in byStatus) byStatus[o.status]++;
|
||||
}
|
||||
|
||||
// Ausgaben nach Kategorie (Zeitraum)
|
||||
const byCategory = {};
|
||||
for (const e of expenses) {
|
||||
byCategory[e.category] = (byCategory[e.category] || 0) + e.amount;
|
||||
}
|
||||
|
||||
// ── Diagramm-Daten: immer nach dem relevanten Datum gruppiert ────────────
|
||||
// Für Diagramme: alle Aufträge (ungefiltert) in Monatsbuckets einordnen
|
||||
// damit das Balkendiagramm den vollen Kontext zeigt (letzte 12 Monate)
|
||||
// ABER: wenn ein konkreter Zeitraum gewählt ist, zeigen wir nur diesen Zeitraum
|
||||
// aufgeteilt in Tages/Wochen/Monats-Buckets je nach Länge
|
||||
|
||||
// Granularität bestimmen
|
||||
let granularity = 'month'; // Standard
|
||||
if (from && to) {
|
||||
const days = (new Date(to) - new Date(from)) / 86400000;
|
||||
if (days <= 14) granularity = 'day';
|
||||
else if (days <= 31) granularity = 'week';
|
||||
// > 31 Tage → month (Monatsauswahl, Jahresauswahl, Gesamt)
|
||||
}
|
||||
|
||||
function getBucketKey(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = dateStr.slice(0, 10); // YYYY-MM-DD, immer lokal
|
||||
if (granularity === 'day') return d;
|
||||
if (granularity === 'week') {
|
||||
// Montag der Woche – rein mit String-Arithmetik (kein UTC-Bug)
|
||||
const [y, m, dd] = d.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, dd); // lokaler Konstruktor
|
||||
const day = dt.getDay() || 7; // 1=Mo … 7=So
|
||||
dt.setDate(dt.getDate() - day + 1);
|
||||
const py = dt.getFullYear();
|
||||
const pm = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const pd = String(dt.getDate()).padStart(2, '0');
|
||||
return `${py}-${pm}-${pd}`;
|
||||
}
|
||||
return d.slice(0, 7); // YYYY-MM
|
||||
}
|
||||
|
||||
// Buckets aus gefilterten Aufträgen (damit der Zeitraum stimmt)
|
||||
const buckets = {};
|
||||
|
||||
// Auch alle Aufträge für Diagramm wenn kein Filter (zeige letzte 12 Monate)
|
||||
const diagramOrders = (from || to) ? filteredOrders : allOrders;
|
||||
|
||||
for (const o of diagramOrders) {
|
||||
const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at;
|
||||
const key = getBucketKey(dateStr);
|
||||
if (!key) continue;
|
||||
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
|
||||
if (o.bezahlt) {
|
||||
buckets[key].revenue += getOrderRevenue(o);
|
||||
buckets[key].base_cost = (buckets[key].base_cost||0) + getOrderBaseCost(o.id);
|
||||
}
|
||||
buckets[key].orders++;
|
||||
}
|
||||
|
||||
// Ausgaben in Buckets (gefiltert)
|
||||
const diagramExpenses = (from || to) ? expenses : db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u);
|
||||
for (const e of diagramExpenses) {
|
||||
const key = getBucketKey(e.date);
|
||||
if (!key) continue;
|
||||
if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 };
|
||||
buckets[key].expenses += e.amount;
|
||||
}
|
||||
|
||||
// Leere Buckets auffüllen – lokaler Date-Konstruktor um UTC-Bug zu vermeiden
|
||||
if (from || to) {
|
||||
const parseLocal = s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d); };
|
||||
const fmtLocal = dt => {
|
||||
const y = dt.getFullYear(), m = String(dt.getMonth()+1).padStart(2,'0'), d = String(dt.getDate()).padStart(2,'0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
const firstBucket = Object.keys(buckets).sort()[0];
|
||||
const startD = parseLocal(from || firstBucket || to);
|
||||
const endD = parseLocal(to || fmtLocal(new Date()));
|
||||
const cur = new Date(startD);
|
||||
while (cur <= endD) {
|
||||
const key = getBucketKey(fmtLocal(cur));
|
||||
if (key && !buckets[key]) buckets[key] = { month:key, revenue:0, base_cost:0, expenses:0, orders:0 };
|
||||
if (granularity === 'day') cur.setDate(cur.getDate() + 1);
|
||||
else if (granularity === 'week') cur.setDate(cur.getDate() + 7);
|
||||
else cur.setMonth(cur.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiert + ohne Filter auf letzte 12 Monate begrenzen
|
||||
let monthlyData = Object.values(buckets)
|
||||
.sort((a,b) => a.month.localeCompare(b.month))
|
||||
.map(m => ({ ...m, revenue: round2(m.revenue), base_cost: round2(m.base_cost||0), expenses: round2(m.expenses) }));
|
||||
|
||||
if (!from && !to) monthlyData = monthlyData.slice(-12);
|
||||
|
||||
// Kumulierter Nettogewinn (pro Bucket)
|
||||
let cum = 0;
|
||||
const cumulativeData = monthlyData.map(m => {
|
||||
cum += (m.revenue||0) - (m.base_cost||0) - (m.expenses||0);
|
||||
return { month: m.month, value: round2(cum) };
|
||||
});
|
||||
|
||||
// Aufträge-Tabelle mit korrekten Werten
|
||||
const recentOrders = filteredOrders.slice(0, 50).map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
}));
|
||||
|
||||
const paidOrdersOut = paidOrders.slice(0,50).map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
totalRevenue: round2(totalRevenue),
|
||||
openRevenue: round2(openRevenue),
|
||||
totalBaseCost: round2(totalBaseCost),
|
||||
totalProfit: round2(totalProfit),
|
||||
totalExpenses: round2(totalExpenses),
|
||||
netProfit: round2(netProfit),
|
||||
totalOrders: filteredOrders.length,
|
||||
granularity,
|
||||
byStatus, byCategory, monthlyData, cumulativeData,
|
||||
expenses: expenses.slice(0, 100),
|
||||
recentOrders,
|
||||
paidOrders: paidOrdersOut,
|
||||
});
|
||||
});
|
||||
|
||||
// Bestellungen nach created_at (für "Bestellungen diesen Monat" Tab)
|
||||
router.get('/month-orders', authenticate, (req, res) => {
|
||||
const { from, to } = req.query;
|
||||
const u = uid(req);
|
||||
let q = 'SELECT * FROM orders WHERE user_id=?';
|
||||
const p = [u];
|
||||
if (from) { q += ' AND DATE(created_at)>=?'; p.push(from); }
|
||||
if (to) { q += ' AND DATE(created_at)<=?'; p.push(to); }
|
||||
q += ' ORDER BY created_at DESC';
|
||||
const orders = db.prepare(q).all(...p);
|
||||
res.json(orders.map(o => ({
|
||||
id: o.id, name: o.name, status: o.status,
|
||||
bezahlt: o.bezahlt, abgeholt: o.abgeholt,
|
||||
created_at: o.created_at, bezahlt_am: o.bezahlt_am,
|
||||
revenue: round2(getOrderRevenue(o)),
|
||||
base_cost: round2(getOrderBaseCost(o.id)),
|
||||
})));
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
257
backend/src/tools/whiteboard/routes.js
Normal file
257
backend/src/tools/whiteboard/routes.js
Normal file
@@ -0,0 +1,257 @@
|
||||
const express = require('express');
|
||||
const db = require('../../db');
|
||||
const { logPush } = require('../../pushLog');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// ── DB-Migration ──────────────────────────────────────────────────────────────
|
||||
(function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS whiteboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT 'Neues Whiteboard',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_data (
|
||||
whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||
elements TEXT NOT NULL DEFAULT '[]',
|
||||
viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'edit',
|
||||
UNIQUE(whiteboard_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_unread (
|
||||
whiteboard_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT 'updated',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
PRIMARY KEY (whiteboard_id, user_id)
|
||||
);
|
||||
`);
|
||||
})();
|
||||
|
||||
const uid = req => req.user.id;
|
||||
|
||||
function getAccessUsers(whiteboardId, exceptUserId) {
|
||||
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
|
||||
if (!wb) return [];
|
||||
const users = new Set();
|
||||
if (wb.owner_id !== exceptUserId) users.add(wb.owner_id);
|
||||
const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(whiteboardId);
|
||||
for (const p of perms) if (p.user_id !== exceptUserId) users.add(p.user_id);
|
||||
return [...users];
|
||||
}
|
||||
|
||||
function markUnread(whiteboardId, userIds, reason = 'updated') {
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO whiteboard_unread (whiteboard_id, user_id, reason, created_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
ON CONFLICT(whiteboard_id, user_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at
|
||||
`);
|
||||
for (const u of userIds) upsert.run(whiteboardId, u, reason);
|
||||
}
|
||||
|
||||
function markRead(whiteboardId, userId) {
|
||||
db.prepare('DELETE FROM whiteboard_unread WHERE whiteboard_id=? AND user_id=?').run(whiteboardId, userId);
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
|
||||
function access(whiteboardId, userId) {
|
||||
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
|
||||
if (!wb) return null;
|
||||
if (wb.owner_id === userId) return 'owner';
|
||||
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId);
|
||||
return perm?.role || null;
|
||||
}
|
||||
|
||||
// ── Unread count ──────────────────────────────────────────────────────────────
|
||||
router.get('/unread', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const { count } = db.prepare('SELECT COUNT(*) as count FROM whiteboard_unread WHERE user_id=?').get(me);
|
||||
res.json({ count });
|
||||
});
|
||||
|
||||
// ── Unread IDs (welche Whiteboards konkret) ───────────────────────────────────
|
||||
router.get('/unread-ids', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const rows = db.prepare('SELECT whiteboard_id FROM whiteboard_unread WHERE user_id=?').all(me);
|
||||
res.json({ ids: rows.map(r => r.whiteboard_id) });
|
||||
});
|
||||
|
||||
// ── Liste ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
// Eigene + geteilte Whiteboards
|
||||
const own = db.prepare(`
|
||||
SELECT w.*, 'owner' as role, u.username as owner_name
|
||||
FROM whiteboards w JOIN users u ON u.id=w.owner_id
|
||||
WHERE w.owner_id=? ORDER BY w.updated_at DESC
|
||||
`).all(me);
|
||||
const shared = db.prepare(`
|
||||
SELECT w.*, p.role, u.username as owner_name
|
||||
FROM whiteboards w
|
||||
JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=?
|
||||
JOIN users u ON u.id=w.owner_id
|
||||
ORDER BY w.updated_at DESC
|
||||
`).all(me);
|
||||
res.json({ whiteboards: [...own, ...shared] });
|
||||
});
|
||||
|
||||
// ── Erstellen ─────────────────────────────────────────────────────────────────
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { title = 'Neues Whiteboard' } = req.body;
|
||||
const me = uid(req);
|
||||
const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard');
|
||||
db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid);
|
||||
res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// ── Umbenennen ────────────────────────────────────────────────────────────────
|
||||
router.patch('/:id/title', authenticate, (req, res) => {
|
||||
const { title } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Löschen ───────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' });
|
||||
db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Canvas laden ──────────────────────────────────────────────────────────────
|
||||
router.get('/:id/data', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
const perms = db.prepare(`
|
||||
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||
`).all(req.params.id);
|
||||
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id);
|
||||
markRead(req.params.id, me);
|
||||
res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms });
|
||||
});
|
||||
|
||||
// ── Canvas speichern ──────────────────────────────────────────────────────────
|
||||
// Feste Route vor :id-Routen
|
||||
router.post('/:id/save', authenticate, async (req, res) => {
|
||||
const me = uid(req);
|
||||
const role = access(req.params.id, me);
|
||||
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' });
|
||||
const { elements, viewport } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=?
|
||||
`).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id);
|
||||
db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
|
||||
// Pushover an alle anderen mit Zugriff (fire & forget)
|
||||
try {
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
const saver = db.prepare('SELECT username FROM users WHERE id=?').get(me);
|
||||
const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`;
|
||||
|
||||
// Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst
|
||||
const notify = getAccessUsers(req.params.id, me);
|
||||
markUnread(req.params.id, notify, 'updated');
|
||||
|
||||
for (const userId of notify) {
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) continue;
|
||||
const title = '🖊 Whiteboard aktualisiert';
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: poCfg.app_token,
|
||||
user: poCfg.user_key,
|
||||
title,
|
||||
message,
|
||||
priority: 0,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
logPush({ userId, title, message, priority: 0, source: 'whiteboard-save' });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ── Berechtigungen: alle User für Share-Modal ─────────────────────────────────
|
||||
router.get('/users-list', authenticate, (req, res) => {
|
||||
const me = uid(req);
|
||||
const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me);
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
// ── Berechtigungen setzen ─────────────────────────────────────────────────────
|
||||
router.put('/:id/permissions', authenticate, async (req, res) => {
|
||||
const me = uid(req);
|
||||
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
|
||||
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' });
|
||||
const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}]
|
||||
if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' });
|
||||
|
||||
// Vorherigen Stand merken um neue User zu erkennen
|
||||
const before = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(req.params.id).map(r => r.user_id);
|
||||
|
||||
const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role");
|
||||
const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?");
|
||||
db.transaction(() => {
|
||||
for (const p of permissions) {
|
||||
if (!p.user_id) continue;
|
||||
if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id);
|
||||
else upsert.run(req.params.id, p.user_id, p.role);
|
||||
}
|
||||
})();
|
||||
|
||||
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(me);
|
||||
const newlyAdded = permissions.filter(p => p.role && p.role !== 'none' && !before.includes(p.user_id));
|
||||
if (newlyAdded.length) markUnread(req.params.id, newlyAdded.map(p => p.user_id), 'shared');
|
||||
|
||||
for (const p of newlyAdded) {
|
||||
try {
|
||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id);
|
||||
if (!poCfg?.app_token || !poCfg?.user_key) continue;
|
||||
const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen';
|
||||
const title = '🖊 Whiteboard geteilt';
|
||||
const message = `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`;
|
||||
fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: poCfg.app_token,
|
||||
user: poCfg.user_key,
|
||||
title,
|
||||
message,
|
||||
priority: 0,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
logPush({ userId: p.user_id, title, message, priority: 0, source: 'whiteboard-share' });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT p.*, u.username FROM whiteboard_permissions p
|
||||
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
|
||||
`).all(req.params.id);
|
||||
res.json({ permissions: updated });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user