Initial commit

This commit is contained in:
2026-07-18 23:50:32 +02:00
commit 8baba7b51f
98 changed files with 30017 additions and 0 deletions

63
backend/src/consoleLog.js Normal file
View 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 };

View 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
View 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
View 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`);

View 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
View 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,
};

View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 };

View 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;

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;