From 29e08a045d5b2be9dedddffeb2c20eaf6e00f3c6 Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 27 May 2026 15:28:46 +0200 Subject: [PATCH] Dateien nach "backend" hochladen --- backend/db.js | 204 +++++++++++++++++++++++++++++++++++++++++ backend/index.js | 48 ++++++++++ backend/nachrichten.js | 168 +++++++++++++++++++++++++++++++++ backend/package.json | 3 +- 4 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 backend/db.js create mode 100644 backend/index.js create mode 100644 backend/nachrichten.js diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..ffae47e --- /dev/null +++ b/backend/db.js @@ -0,0 +1,204 @@ +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 user_keys ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS 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, + deleted_by_sender INTEGER NOT NULL DEFAULT 0, + deleted_by_recipient INTEGER NOT NULL DEFAULT 0 + ); + + 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"); + +// 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"); +module.exports = db; diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..94c8360 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,48 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); + +require('./db'); + +const app = express(); +app.use(express.json()); + +// ── 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/nachrichten', require('./routes/nachrichten')); +app.use('/api/dashboard', require('./routes/dashboard')); +app.use('/api/system', require('./routes/system')); + +// 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}`); + } + }); + +// ── Frontend ────────────────────────────────────────────────────────────────── +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)); +app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html'))); + +app.listen(4000, () => console.log('πŸš€ Dicken Dock lΓ€uft auf Port 4000')); diff --git a/backend/nachrichten.js b/backend/nachrichten.js new file mode 100644 index 0000000..063f6e0 --- /dev/null +++ b/backend/nachrichten.js @@ -0,0 +1,168 @@ +const express = require('express'); +const webpush = require('web-push'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); +const router = express.Router(); + +// ── VAPID-Setup (einmalig, in DB gespeichert) ───────────────────────────────── +function getVapidKeys() { + const pub = db.prepare("SELECT value FROM admin_settings WHERE key='vapid_public'").get(); + const priv = db.prepare("SELECT value FROM admin_settings WHERE key='vapid_private'").get(); + if (pub && priv) return { publicKey: pub.value, privateKey: priv.value }; + const keys = webpush.generateVAPIDKeys(); + db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('vapid_public',?)").run(keys.publicKey); + db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('vapid_private',?)").run(keys.privateKey); + return keys; +} +const vapid = getVapidKeys(); +webpush.setVapidDetails('mailto:admin@dickendock.local', vapid.publicKey, vapid.privateKey); + +// GET /vapid-key – ΓΆffentlicher VAPID-SchlΓΌssel fΓΌr den Browser +router.get('/vapid-key', authenticate, (req, res) => { + res.json({ publicKey: vapid.publicKey }); +}); + +// ── Γ–ffentliche SchlΓΌssel ───────────────────────────────────────────────────── +// POST /keys – eigenen ΓΆffentlichen SchlΓΌssel speichern +router.post('/keys', authenticate, (req, res) => { + const { publicKey } = req.body; + if (!publicKey) return res.status(400).json({ error: 'Kein SchlΓΌssel' }); + db.prepare('INSERT OR REPLACE INTO user_keys (user_id,public_key,updated_at) VALUES (?,?,CURRENT_TIMESTAMP)').run(req.user.id, publicKey); + res.json({ success: true }); +}); + +// GET /keys/:username – ΓΆffentlichen SchlΓΌssel eines Users abrufen +router.get('/keys/:username', authenticate, (req, res) => { + const user = db.prepare('SELECT id FROM users WHERE username=?').get(req.params.username); + if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); + const key = db.prepare('SELECT public_key FROM user_keys WHERE user_id=?').get(user.id); + if (!key) return res.status(404).json({ error: 'Noch kein SchlΓΌssel hinterlegt' }); + res.json({ publicKey: key.public_key }); +}); + +// ── Push-Subscriptions ──────────────────────────────────────────────────────── +router.post('/subscribe', authenticate, (req, res) => { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) return res.status(400).json({ error: 'UngΓΌltige Subscription' }); + db.prepare('INSERT OR REPLACE INTO push_subscriptions (user_id,endpoint,p256dh,auth) VALUES (?,?,?,?)') + .run(req.user.id, endpoint, keys.p256dh, keys.auth); + res.json({ success: true }); +}); + +router.delete('/subscribe', authenticate, (req, res) => { + db.prepare('DELETE FROM push_subscriptions WHERE user_id=?').run(req.user.id); + res.json({ success: true }); +}); + +// ── Nachrichten ─────────────────────────────────────────────────────────────── +// GET /conversations – alle GesprΓ€chspartner mit ungelesener Anzahl +router.get('/conversations', authenticate, (req, res) => { + const uid = req.user.id; + const convs = db.prepare(` + SELECT + CASE WHEN m.sender_id=? THEN m.recipient_id ELSE m.sender_id END AS partner_id, + u.username AS partner_name, + MAX(m.created_at) AS last_at, + COUNT(CASE WHEN m.recipient_id=? AND m.deleted_by_recipient=0 THEN 1 END) AS unread + FROM messages m + JOIN users u ON u.id=(CASE WHEN m.sender_id=? THEN m.recipient_id ELSE m.sender_id END) + WHERE (m.sender_id=? AND m.deleted_by_sender=0) + OR (m.recipient_id=? AND m.deleted_by_recipient=0) + GROUP BY partner_id + ORDER BY last_at DESC + `).all(uid, uid, uid, uid, uid); + res.json(convs); +}); + +// GET /unread-count – Gesamtanzahl ungelesener Nachrichten +router.get('/unread-count', authenticate, (req, res) => { + const count = db.prepare('SELECT COUNT(*) c FROM messages WHERE recipient_id=? AND deleted_by_recipient=0').get(req.user.id).c; + res.json({ count }); +}); + +// GET /with/:username – Nachrichten mit einem User +router.get('/with/:username', authenticate, (req, res) => { + const uid = req.user.id; + const partner = db.prepare('SELECT id FROM users WHERE username=?').get(req.params.username); + if (!partner) return res.status(404).json({ error: 'User nicht gefunden' }); + const msgs = db.prepare(` + SELECT m.id, m.sender_id, m.encrypted_content, m.iv, m.created_at, + s.username AS sender_name + FROM messages m + JOIN users s ON s.id=m.sender_id + WHERE ((m.sender_id=? AND m.recipient_id=? AND m.deleted_by_sender=0) + OR (m.sender_id=? AND m.recipient_id=? AND m.deleted_by_recipient=0)) + ORDER BY m.created_at ASC + `).all(uid, partner.id, partner.id, uid); + res.json(msgs); +}); + +// GET /users – alle User außer sich selbst (fΓΌr neues GesprΓ€ch) +router.get('/users', authenticate, (req, res) => { + const users = db.prepare('SELECT id,username FROM users WHERE id!=? ORDER BY username').all(req.user.id); + res.json(users); +}); + +// POST /send – verschlΓΌsselte Nachricht senden +router.post('/send', authenticate, async (req, res) => { + const { recipient_username, encrypted_content, iv } = req.body; + if (!recipient_username || !encrypted_content || !iv) + return res.status(400).json({ error: 'Fehlende Felder' }); + const recipient = db.prepare('SELECT id FROM users WHERE username=?').get(recipient_username); + if (!recipient) return res.status(404).json({ error: 'EmpfΓ€nger nicht gefunden' }); + + const r = db.prepare('INSERT INTO messages (sender_id,recipient_id,encrypted_content,iv) VALUES (?,?,?,?)') + .run(req.user.id, recipient.id, encrypted_content, iv); + + // Push-Benachrichtigung an EmpfΓ€nger + const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(recipient.id); + const sender = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id); + for (const sub of subs) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify({ title: `Nachricht von ${sender.username}`, body: 'πŸ”’ VerschlΓΌsselte Nachricht', icon: '/favicon.svg' }) + ); + } catch(e) { + if (e.statusCode === 410) db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(sub.endpoint); + } + } + + res.json({ id: r.lastInsertRowid, success: true }); +}); + +// DELETE /:id – Nachricht fΓΌr sich lΓΆschen +router.delete('/:id', authenticate, (req, res) => { + const uid = req.user.id; + const msg = db.prepare('SELECT * FROM messages WHERE id=?').get(req.params.id); + if (!msg) return res.status(404).json({ error: 'Nicht gefunden' }); + if (msg.sender_id !== uid && msg.recipient_id !== uid) + return res.status(403).json({ error: 'Kein Zugriff' }); + + if (msg.sender_id === uid) db.prepare('UPDATE messages SET deleted_by_sender=1 WHERE id=?').run(msg.id); + else db.prepare('UPDATE messages SET deleted_by_recipient=1 WHERE id=?').run(msg.id); + + // Wenn beide gelΓΆscht β†’ komplett entfernen + const updated = db.prepare('SELECT * FROM messages WHERE id=?').get(msg.id); + if (updated?.deleted_by_sender && updated?.deleted_by_recipient) + db.prepare('DELETE FROM messages WHERE id=?').run(msg.id); + + res.json({ success: true }); +}); + +// DELETE /conversation/:username – ganzes GesprΓ€ch fΓΌr sich lΓΆschen +router.delete('/conversation/:username', authenticate, (req, res) => { + const uid = req.user.id; + const partner = db.prepare('SELECT id FROM users WHERE username=?').get(req.params.username); + if (!partner) return res.status(404).json({ error: 'User nicht gefunden' }); + + db.prepare('UPDATE messages SET deleted_by_sender=1 WHERE sender_id=? AND recipient_id=?').run(uid, partner.id); + db.prepare('UPDATE messages SET deleted_by_recipient=1 WHERE recipient_id=? AND sender_id=?').run(uid, partner.id); + // Komplett lΓΆschen wenn beide weg + db.prepare('DELETE FROM messages WHERE sender_id IN (?,?) AND recipient_id IN (?,?) AND deleted_by_sender=1 AND deleted_by_recipient=1') + .run(uid, partner.id, uid, partner.id); + + res.json({ success: true }); +}); + +module.exports = router; diff --git a/backend/package.json b/backend/package.json index 40bd080..6ce4cc4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "better-sqlite3": "^9.4.3", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "web-push": "^3.6.7" } }