diff --git a/package.json b/package.json index 9e644fd..f918caf 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "dependencies": { "bcrypt": "^5.1.1", "better-sqlite3": "^9.6.0", - "connect-sqlite3": "^0.9.15", - "dotenv": "^16.4.7", +"dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", diff --git a/public/pages/budget.js b/public/pages/budget.js index c961e13..176d052 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -28,6 +28,7 @@ let state = { entries: [], summary: null, }; +let _container = null; // -------------------------------------------------------- // Formatierung @@ -67,6 +68,7 @@ async function loadMonth(month) { // -------------------------------------------------------- export async function render(container, { user }) { + _container = container; const today = new Date(); state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; @@ -103,17 +105,17 @@ export async function render(container, { user }) { // -------------------------------------------------------- function wireNav() { - document.getElementById('budget-prev').addEventListener('click', async () => { + _container.querySelector('#budget-prev').addEventListener('click', async () => { await loadMonth(addMonths(state.month, -1)); renderBody(); updateLabel(); }); - document.getElementById('budget-next').addEventListener('click', async () => { + _container.querySelector('#budget-next').addEventListener('click', async () => { await loadMonth(addMonths(state.month, 1)); renderBody(); updateLabel(); }); - document.getElementById('budget-today').addEventListener('click', async () => { + _container.querySelector('#budget-today').addEventListener('click', async () => { const today = new Date(); const m = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; if (m === state.month) return; @@ -121,12 +123,12 @@ function wireNav() { renderBody(); updateLabel(); }); - document.getElementById('budget-add').addEventListener('click', () => openModal({ mode: 'create' })); + _container.querySelector('#budget-add').addEventListener('click', () => openModal({ mode: 'create' })); updateLabel(); } function updateLabel() { - const lbl = document.getElementById('budget-label'); + const lbl = _container.querySelector('#budget-label'); if (lbl) lbl.textContent = formatMonthLabel(state.month); } @@ -135,7 +137,7 @@ function updateLabel() { // -------------------------------------------------------- function renderBody() { - const body = document.getElementById('budget-body'); + const body = _container.querySelector('#budget-body'); if (!body) return; updateLabel(); @@ -186,7 +188,7 @@ function renderBody() { if (window.lucide) lucide.createIcons(); - document.getElementById('budget-list')?.addEventListener('click', async (e) => { + _container.querySelector('#budget-list')?.addEventListener('click', async (e) => { const delBtn = e.target.closest('[data-action="delete"]'); if (delBtn) { await deleteEntry(parseInt(delBtn.dataset.id, 10)); return; } @@ -262,7 +264,7 @@ function formatEntryDate(dateStr) { // -------------------------------------------------------- function openModal({ mode, entry = null }) { - document.getElementById('budget-modal-overlay')?.remove(); + document.querySelector('#budget-modal-overlay')?.remove(); const isEdit = mode === 'edit'; const today = new Date().toISOString().slice(0, 10); diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 2af845f..7ac1127 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -38,6 +38,7 @@ let state = { rangeFrom: '', rangeTo: '', }; +let _container = null; // -------------------------------------------------------- // Datumshelfer @@ -130,7 +131,7 @@ async function loadRange(from, to) { async function loadUsers() { try { - const res = await api.get('/users'); + const res = await api.get('/auth/users'); state.users = res.data; } catch { state.users = []; @@ -142,6 +143,7 @@ async function loadUsers() { // -------------------------------------------------------- export async function render(container, { user }) { + _container = container; state.today = isoDate(new Date()); state.cursor = state.today; state.view = 'month'; @@ -165,7 +167,7 @@ export async function render(container, { user }) { // -------------------------------------------------------- function renderToolbar() { - const bar = document.getElementById('cal-toolbar'); + const bar = _container.querySelector('#cal-toolbar'); if (!bar) return; bar.innerHTML = ` @@ -216,7 +218,7 @@ function renderToolbar() { } function updateLabel() { - const lbl = document.getElementById('cal-label'); + const lbl = _container.querySelector('#cal-label'); if (!lbl) return; const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); @@ -273,7 +275,7 @@ async function reloadForView() { // -------------------------------------------------------- function renderView() { - const body = document.getElementById('cal-body'); + const body = _container.querySelector('#cal-body'); if (!body) return; body.innerHTML = ''; @@ -624,7 +626,7 @@ function renderAgendaEvent(ev) { // -------------------------------------------------------- function showEventPopup(ev, anchor) { - document.getElementById('event-popup')?.remove(); + document.querySelector('#event-popup')?.remove(); const popup = document.createElement('div'); popup.id = 'event-popup'; @@ -689,7 +691,7 @@ function showEventPopup(ev, anchor) { // -------------------------------------------------------- function openEventModal({ mode, event = null, date = null }) { - document.getElementById('event-modal-overlay')?.remove(); + document.querySelector('#event-modal-overlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'event-modal-overlay'; @@ -860,15 +862,15 @@ function buildEventModalHTML({ mode, event, date }) { // Allday-Toggle: Felder umschalten document.addEventListener('change', (e) => { if (e.target.id !== 'modal-allday') return; - const tf = document.getElementById('time-fields'); - const af = document.getElementById('allday-fields'); + const tf = document.querySelector('#time-fields'); + const af = document.querySelector('#allday-fields'); if (!tf || !af) return; if (e.target.checked) { tf.style.display = 'none'; af.style.display = ''; } else { tf.style.display = ''; af.style.display = 'none'; } }); function closeEventModal() { - document.getElementById('event-modal-overlay')?.remove(); + document.querySelector('#event-modal-overlay')?.remove(); } async function saveEvent(overlay, mode, eventId) { diff --git a/public/pages/contacts.js b/public/pages/contacts.js index 54d2203..733bfe8 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -32,12 +32,14 @@ let state = { activeCategory: null, searchQuery: '', }; +let _container = null; // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { + _container = container; container.innerHTML = `
@@ -70,7 +72,7 @@ export async function render(container, { user }) { // Suche let searchTimer; - document.getElementById('contacts-search').addEventListener('input', (e) => { + _container.querySelector('#contacts-search').addEventListener('input', (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.searchQuery = e.target.value.trim(); @@ -79,10 +81,10 @@ export async function render(container, { user }) { }); // Kategorie-Filter - document.getElementById('contacts-filters').addEventListener('click', (e) => { + _container.querySelector('#contacts-filters').addEventListener('click', (e) => { const chip = e.target.closest('[data-cat]'); if (!chip) return; - document.querySelectorAll('.contact-filter-chip').forEach((c) => + _container.querySelectorAll('.contact-filter-chip').forEach((c) => c.classList.toggle('contact-filter-chip--active', c === chip) ); state.activeCategory = chip.dataset.cat || null; @@ -90,7 +92,7 @@ export async function render(container, { user }) { }); // Neu - document.getElementById('contacts-add-btn').addEventListener('click', () => + _container.querySelector('#contacts-add-btn').addEventListener('click', () => openModal({ mode: 'create' }) ); } @@ -119,7 +121,7 @@ function filterContacts() { } function renderList() { - const container = document.getElementById('contacts-list'); + const container = _container.querySelector('#contacts-list'); if (!container) return; const contacts = filterContacts(); @@ -196,7 +198,7 @@ function renderContactItem(c) { // -------------------------------------------------------- function openModal({ mode, contact = null }) { - document.getElementById('contact-modal-overlay')?.remove(); + document.querySelector('#contact-modal-overlay')?.remove(); const isEdit = mode === 'edit'; const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : ''); diff --git a/public/pages/login.js b/public/pages/login.js index 35602fc..eb751a8 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -76,8 +76,8 @@ export async function render(container) { submitBtn.textContent = 'Wird angemeldet …'; try { - await auth.login(username, password); - window.oikos.navigate('/'); + const result = await auth.login(username, password); + window.oikos.navigate('/', result.user); } catch (err) { showError(errorEl, err.status === 429 ? 'Zu viele Versuche. Bitte warte kurz.' diff --git a/public/pages/meals.js b/public/pages/meals.js index 84ff9ee..b58d703 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -30,6 +30,9 @@ let state = { modal: null, }; +// Container-Referenz für Hilfsfunktionen (wird in render() gesetzt) +let _container = null; + // -------------------------------------------------------- // Datumshelfer // -------------------------------------------------------- @@ -90,6 +93,7 @@ async function loadLists() { // -------------------------------------------------------- export async function render(container, { user }) { + _container = container; container.innerHTML = `
@@ -123,10 +127,10 @@ export async function render(container, { user }) { // -------------------------------------------------------- function renderWeekGrid() { - const grid = document.getElementById('week-grid'); + const grid = _container.querySelector('#week-grid'); if (!grid) return; - document.getElementById('week-label').textContent = + _container.querySelector('#week-label').textContent = formatWeekLabel(state.currentWeek); const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i)); @@ -211,17 +215,17 @@ function renderSlot(date, type, mealsForDay) { // -------------------------------------------------------- function wireNav() { - document.getElementById('week-prev')?.addEventListener('click', async () => { + _container.querySelector('#week-prev')?.addEventListener('click', async () => { await loadWeek(addDays(state.currentWeek, -7)); renderWeekGrid(); }); - document.getElementById('week-next')?.addEventListener('click', async () => { + _container.querySelector('#week-next')?.addEventListener('click', async () => { await loadWeek(addDays(state.currentWeek, 7)); renderWeekGrid(); }); - document.getElementById('week-today')?.addEventListener('click', async () => { + _container.querySelector('#week-today')?.addEventListener('click', async () => { const monday = getMondayOf(new Date().toISOString().slice(0, 10)); if (monday === state.currentWeek) return; await loadWeek(monday); @@ -272,7 +276,7 @@ function wireGrid(grid) { function openModal(opts) { state.modal = opts; - document.getElementById('meal-modal-overlay')?.remove(); + document.querySelector('#meal-modal-overlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'meal-modal-overlay'; @@ -465,7 +469,7 @@ function ingredientRowHTML(name, qty, id) { } function closeModal() { - document.getElementById('meal-modal-overlay')?.remove(); + document.querySelector('#meal-modal-overlay')?.remove(); state.modal = null; } diff --git a/public/pages/notes.js b/public/pages/notes.js index b743788..ebbd079 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -20,6 +20,7 @@ const NOTE_COLORS = [ // -------------------------------------------------------- let state = { notes: [], user: null }; +let _container = null; // -------------------------------------------------------- // Markdown-Light Renderer @@ -39,6 +40,7 @@ function renderMarkdownLight(text) { // -------------------------------------------------------- export async function render(container, { user }) { + _container = container; state.user = user; container.innerHTML = ` @@ -60,7 +62,7 @@ export async function render(container, { user }) { state.notes = res.data; renderGrid(); - document.getElementById('notes-add-btn').addEventListener('click', () => openModal({ mode: 'create' })); + _container.querySelector('#notes-add-btn').addEventListener('click', () => openModal({ mode: 'create' })); } // -------------------------------------------------------- @@ -68,7 +70,7 @@ export async function render(container, { user }) { // -------------------------------------------------------- function renderGrid() { - const grid = document.getElementById('notes-grid'); + const grid = _container.querySelector('#notes-grid'); if (!grid) return; if (!state.notes.length) { @@ -140,7 +142,7 @@ function renderNoteCard(note) { // -------------------------------------------------------- function openModal({ mode, note = null }) { - document.getElementById('note-modal-overlay')?.remove(); + document.querySelector('#note-modal-overlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'note-modal-overlay'; diff --git a/public/router.js b/public/router.js index 3b3aeb4..8eb09c8 100644 --- a/public/router.js +++ b/public/router.js @@ -48,9 +48,18 @@ let currentPath = null; /** * Navigiert zu einem Pfad und rendert die entsprechende Seite. * @param {string} path + * @param {Object|boolean} userOrPushState - Direkt ein User-Objekt nach Login, + * oder boolean (pushState) für interne Navigation * @param {boolean} pushState - false beim initialen Load und popstate */ -async function navigate(path, pushState = true) { +async function navigate(path, userOrPushState = true, pushState = true) { + // Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init + if (typeof userOrPushState === 'object' && userOrPushState !== null) { + currentUser = userOrPushState; + } else { + pushState = userOrPushState; + } + if (path === currentPath) return; currentPath = path; @@ -100,21 +109,23 @@ async function renderPage(route) { throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`); } - // Seiten-Wrapper erstellen - const pageWrapper = document.createElement('div'); - pageWrapper.className = 'page-transition'; - pageWrapper.style.animation = 'page-in 0.2s ease forwards'; - - await module.render(pageWrapper, { user: currentUser }); - - // Nav + Content einmalig aufbauen (beim ersten Render) + // App-Shell einmalig aufbauen BEVOR render() aufgerufen wird — + // page-content muss im DOM existieren damit document.getElementById() + // in Seiten-Modulen funktioniert. if (!document.querySelector('.nav-bottom') && currentUser) { renderAppShell(app); } + // Seiten-Wrapper bereits jetzt in den DOM einfügen, damit + // document.getElementById() in render() die richtigen Elemente findet. + const pageWrapper = document.createElement('div'); + pageWrapper.className = 'page-transition'; + pageWrapper.style.animation = 'page-in 0.2s ease forwards'; const content = document.getElementById('page-content') || app; content.replaceChildren(pageWrapper); + await module.render(pageWrapper, { user: currentUser }); + } catch (err) { console.error('[Router] Seiten-Render-Fehler:', err); renderError(app, err); @@ -199,9 +210,10 @@ function renderError(container, err) {
Etwas ist schiefgelaufen.
${err.message}
- +
`; + container.querySelector('#error-reload-btn')?.addEventListener('click', () => location.reload()); } // -------------------------------------------------------- diff --git a/public/sw.js b/public/sw.js index c8ea2c5..8b5264c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -12,9 +12,9 @@ * API: Immer Netzwerk (kein Caching von Nutzerdaten) */ -const SHELL_CACHE = 'oikos-shell-v5'; -const PAGES_CACHE = 'oikos-pages-v5'; -const ASSETS_CACHE = 'oikos-assets-v5'; +const SHELL_CACHE = 'oikos-shell-v8'; +const PAGES_CACHE = 'oikos-pages-v8'; +const ASSETS_CACHE = 'oikos-assets-v8'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; // App-Shell: sofort benötigt für ersten Render diff --git a/server/auth.js b/server/auth.js index 4f114f5..21b1090 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,7 +1,7 @@ /** * Modul: Authentifizierung (Auth) * Zweck: Login-Route, Session-Middleware, Auth-Guard für geschützte Routen - * Abhängigkeiten: express, bcrypt, express-session, connect-sqlite3, server/db.js + * Abhängigkeiten: express, bcrypt, express-session, server/db.js */ 'use strict'; @@ -17,15 +17,75 @@ const { generateToken } = require('./middleware/csrf'); const router = express.Router(); // -------------------------------------------------------- -// Session-Store (SQLite) +// Session-Store (better-sqlite3, gleiche DB-Instanz wie App) +// Eigene Implementierung — kein connect-sqlite3 (nutzt sqlite3-Bindings, +// die separat kompiliert werden müssten und die Fehlerquelle waren). // -------------------------------------------------------- -const SQLiteStore = require('connect-sqlite3')(session); +class BetterSQLiteStore extends session.Store { + constructor() { + super(); + // Tabelle anlegen falls nicht vorhanden + db.get().exec(` + CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + sess TEXT NOT NULL, + expired_at INTEGER NOT NULL + ) + `); + // Abgelaufene Sessions regelmäßig aufräumen (alle 15 Minuten) + setInterval(() => { + db.get().prepare('DELETE FROM sessions WHERE expired_at <= ?').run(Date.now()); + }, 15 * 60_000).unref(); + } -const sessionStore = new SQLiteStore({ - db: 'sessions.db', - dir: process.env.DB_PATH ? require('path').dirname(process.env.DB_PATH) : '.', - ttl: 60 * 60 * 24 * 7, // 7 Tage in Sekunden -}); + get(sid, callback) { + try { + const row = db.get() + .prepare('SELECT sess FROM sessions WHERE sid = ? AND expired_at > ?') + .get(sid, Date.now()); + callback(null, row ? JSON.parse(row.sess) : null); + } catch (err) { + callback(err); + } + } + + set(sid, sess, callback) { + try { + const ttl = sess.cookie?.maxAge ?? 7 * 24 * 60 * 60 * 1000; + const expiredAt = Date.now() + ttl; + db.get() + .prepare('INSERT OR REPLACE INTO sessions (sid, sess, expired_at) VALUES (?, ?, ?)') + .run(sid, JSON.stringify(sess), expiredAt); + callback(null); + } catch (err) { + callback(err); + } + } + + destroy(sid, callback) { + try { + db.get().prepare('DELETE FROM sessions WHERE sid = ?').run(sid); + callback(null); + } catch (err) { + callback(err); + } + } + + touch(sid, sess, callback) { + try { + const ttl = sess.cookie?.maxAge ?? 7 * 24 * 60 * 60 * 1000; + const expiredAt = Date.now() + ttl; + db.get() + .prepare('UPDATE sessions SET expired_at = ? WHERE sid = ?') + .run(expiredAt, sid); + callback(null); + } catch (err) { + callback(err); + } + } +} + +const sessionStore = new BetterSQLiteStore(); /** * Session-Middleware konfigurieren. diff --git a/server/index.js b/server/index.js index 0b8900d..46e96c1 100644 --- a/server/index.js +++ b/server/index.js @@ -11,20 +11,19 @@ const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const path = require('path'); -const db = require('./db'); + +// -------------------------------------------------------- +// Datenbank initialisieren (muss vor require('./auth') stehen, +// da BetterSQLiteStore im Konstruktor db.get() aufruft) +// -------------------------------------------------------- +const db = require('./db'); +db.init(); + const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth'); const { csrfMiddleware } = require('./middleware/csrf'); const googleCalendar = require('./services/google-calendar'); const appleCalendar = require('./services/apple-calendar'); -const app = express(); -const PORT = process.env.PORT || 3000; - -// -------------------------------------------------------- -// Datenbank initialisieren -// -------------------------------------------------------- -db.init(); - // -------------------------------------------------------- // Security-Middleware // --------------------------------------------------------