6e0eda8ba4
- Fix SQLCipher PRAGMA key interpolation (hex-encode key to prevent crash on single quotes) - Enforce min password length (8 chars) on admin user creation - Add length bounds on username/display_name and login inputs - Invalidate other sessions on password change - Multi-stage Docker build (exclude build tools from runtime) - Exclude docs/ from Docker image - Consolidate dotenv.config() to single entry point - Document flat family authorization model in SECURITY.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
15 KiB
JavaScript
369 lines
15 KiB
JavaScript
/**
|
|
* Modul: Datenbank (Database)
|
|
* Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer
|
|
* Abhängigkeiten: better-sqlite3
|
|
*
|
|
* SQLCipher-Hinweis:
|
|
* Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde.
|
|
* Im Docker-Container (Dockerfile: libsqlcipher-dev + npm rebuild) ist das gewährleistet.
|
|
* Ohne DB_ENCRYPTION_KEY gesetzt läuft die App mit unverschlüsseltem SQLite (für Entwicklung).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const Database = require('better-sqlite3');
|
|
const path = require('path');
|
|
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'oikos.db');
|
|
const DB_KEY = process.env.DB_ENCRYPTION_KEY;
|
|
|
|
let db;
|
|
|
|
// --------------------------------------------------------
|
|
// Initialisierung
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Datenbankverbindung öffnen, SQLCipher-Key setzen, Migrations ausführen.
|
|
* Einmalig beim Serverstart aufrufen.
|
|
* @returns {import('better-sqlite3').Database}
|
|
*/
|
|
function init() {
|
|
db = new Database(DB_PATH);
|
|
|
|
if (DB_KEY) {
|
|
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker)
|
|
db.pragma(`key=x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'`);
|
|
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
|
|
try {
|
|
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
|
} catch {
|
|
throw new Error('[DB] Falscher Verschlüsselungsschlüssel oder keine SQLCipher-Unterstützung.');
|
|
}
|
|
}
|
|
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.pragma('synchronous = NORMAL');
|
|
db.pragma('temp_store = MEMORY');
|
|
|
|
migrate();
|
|
|
|
console.log(`[DB] Verbunden: ${DB_PATH} | Schema v${currentVersion()}`);
|
|
return db;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Migrations-Engine
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Alle Migrationen in aufsteigender Reihenfolge.
|
|
* Neue Migrations am Ende anhängen — niemals bestehende ändern.
|
|
*/
|
|
const MIGRATIONS = [
|
|
{
|
|
version: 1,
|
|
description: 'Initiales Schema',
|
|
up: `
|
|
-- Benutzer
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
avatar_color TEXT NOT NULL DEFAULT '#007AFF',
|
|
role TEXT NOT NULL DEFAULT 'member'
|
|
CHECK(role IN ('admin', 'member')),
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Aufgaben
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
priority TEXT NOT NULL DEFAULT 'medium'
|
|
CHECK(priority IN ('low', 'medium', 'high', 'urgent')),
|
|
status TEXT NOT NULL DEFAULT 'open'
|
|
CHECK(status IN ('open', 'in_progress', 'done')),
|
|
due_date TEXT,
|
|
due_time TEXT,
|
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
is_recurring INTEGER NOT NULL DEFAULT 0,
|
|
recurrence_rule TEXT,
|
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Einkaufslisten
|
|
CREATE TABLE IF NOT EXISTS shopping_lists (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Essensplan (muss vor shopping_items stehen wegen FK-Referenz)
|
|
CREATE TABLE IF NOT EXISTS meals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL,
|
|
meal_type TEXT NOT NULL
|
|
CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
|
title TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Einkaufsartikel (nach meals, wegen added_from_meal FK)
|
|
CREATE TABLE IF NOT EXISTS shopping_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
quantity TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
is_checked INTEGER NOT NULL DEFAULT 0,
|
|
added_from_meal INTEGER REFERENCES meals(id) ON DELETE SET NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Mahlzeit-Zutaten
|
|
CREATE TABLE IF NOT EXISTS meal_ingredients (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
quantity TEXT,
|
|
on_shopping_list INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Kalender-Events
|
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
start_datetime TEXT NOT NULL,
|
|
end_datetime TEXT,
|
|
all_day INTEGER NOT NULL DEFAULT 0,
|
|
location TEXT,
|
|
color TEXT NOT NULL DEFAULT '#007AFF',
|
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
external_calendar_id TEXT,
|
|
external_source TEXT NOT NULL DEFAULT 'local'
|
|
CHECK(external_source IN ('local', 'google', 'apple')),
|
|
recurrence_rule TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Pinnwand / Notizen
|
|
CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT,
|
|
content TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT '#FFEB3B',
|
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Kontakte
|
|
CREATE TABLE IF NOT EXISTS contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
phone TEXT,
|
|
email TEXT,
|
|
address TEXT,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- Budget
|
|
CREATE TABLE IF NOT EXISTS budget_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
date TEXT NOT NULL,
|
|
is_recurring INTEGER NOT NULL DEFAULT 0,
|
|
recurrence_rule TEXT,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
-- --------------------------------------------------------
|
|
-- updated_at Trigger (automatisch bei UPDATE setzen)
|
|
-- --------------------------------------------------------
|
|
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
|
AFTER UPDATE ON users FOR EACH ROW
|
|
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_tasks_updated_at
|
|
AFTER UPDATE ON tasks FOR EACH ROW
|
|
BEGIN UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_shopping_lists_updated_at
|
|
AFTER UPDATE ON shopping_lists FOR EACH ROW
|
|
BEGIN UPDATE shopping_lists SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_shopping_items_updated_at
|
|
AFTER UPDATE ON shopping_items FOR EACH ROW
|
|
BEGIN UPDATE shopping_items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_meals_updated_at
|
|
AFTER UPDATE ON meals FOR EACH ROW
|
|
BEGIN UPDATE meals SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_meal_ingredients_updated_at
|
|
AFTER UPDATE ON meal_ingredients FOR EACH ROW
|
|
BEGIN UPDATE meal_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_calendar_events_updated_at
|
|
AFTER UPDATE ON calendar_events FOR EACH ROW
|
|
BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_notes_updated_at
|
|
AFTER UPDATE ON notes FOR EACH ROW
|
|
BEGIN UPDATE notes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
|
AFTER UPDATE ON contacts FOR EACH ROW
|
|
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
|
|
AFTER UPDATE ON budget_entries FOR EACH ROW
|
|
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
-- --------------------------------------------------------
|
|
-- Indizes
|
|
-- --------------------------------------------------------
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_shopping_items_list ON shopping_items(list_id);
|
|
CREATE INDEX IF NOT EXISTS idx_meals_date ON meals(date);
|
|
CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime);
|
|
CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to);
|
|
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
|
|
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
|
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
|
`,
|
|
},
|
|
{
|
|
version: 2,
|
|
description: 'Sync-Konfigurationstabelle für Google/Apple Calendar',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS sync_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 3,
|
|
description: 'Wiederkehrende Budget-Einträge: parent-Referenz und Skip-Tabelle',
|
|
up: `
|
|
ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER
|
|
REFERENCES budget_entries(id) ON DELETE SET NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS budget_recurrence_skipped (
|
|
parent_id INTEGER NOT NULL REFERENCES budget_entries(id) ON DELETE CASCADE,
|
|
month TEXT NOT NULL,
|
|
PRIMARY KEY (parent_id, month)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_budget_parent ON budget_entries(recurrence_parent_id);
|
|
`,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Führt alle ausstehenden Migrations in einer Transaktion aus.
|
|
*/
|
|
function migrate() {
|
|
// Migrations-Versions-Tabelle sicherstellen (außerhalb der Haupt-Transaktion)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
description TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
`);
|
|
|
|
const applied = new Set(
|
|
db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version)
|
|
);
|
|
|
|
const pending = MIGRATIONS.filter((m) => !applied.has(m.version));
|
|
|
|
if (pending.length === 0) return;
|
|
|
|
const runMigration = db.transaction((migration) => {
|
|
db.exec(migration.up);
|
|
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
|
|
.run(migration.version, migration.description);
|
|
console.log(`[DB] Migration ${migration.version} angewendet: ${migration.description}`);
|
|
});
|
|
|
|
for (const migration of pending) {
|
|
runMigration(migration);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aktuelle Schema-Version zurückgeben.
|
|
* @returns {number}
|
|
*/
|
|
function currentVersion() {
|
|
if (!db) return 0;
|
|
try {
|
|
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
|
|
return row?.v ?? 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Öffentliche API
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Datenbankinstanz zurückgeben.
|
|
* @returns {import('better-sqlite3').Database}
|
|
*/
|
|
function get() {
|
|
if (!db) throw new Error('[DB] Nicht initialisiert — init() zuerst aufrufen.');
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* Transaktion-Helfer: Funktion wird atomar ausgeführt.
|
|
* Bei Fehler wird automatisch rollback ausgeführt.
|
|
* @param {Function} fn
|
|
* @returns {any}
|
|
*/
|
|
function transaction(fn) {
|
|
return get().transaction(fn)();
|
|
}
|
|
|
|
module.exports = { init, get, transaction, currentVersion };
|