## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import sqlite3 import os from hashlib import sha256 from pathlib import Path from uuid import uuid4 BASE_DIR = Path(__file__).resolve().parent.parent DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db')) DB_PATH.parent.mkdir(parents=True, exist_ok=True) AVATARS_DIR = DB_PATH.parent / 'avatars' AVATARS_DIR.mkdir(parents=True, exist_ok=True) def hash_password(password: str) -> str: return sha256(password.encode('utf-8')).hexdigest() DEFAULT_TAGS = ('#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI') MIGRATIONS = [ (1, ''' CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, avatar_url TEXT, bio TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, token_type TEXT NOT NULL DEFAULT 'access', expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS links ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, comment TEXT, timestamp TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, is_public INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS plugins ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, version TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_plugin_config ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, plugin_name TEXT NOT NULL, config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, plugin_name), FOREIGN KEY(user_id) REFERENCES users(id) ); '''), (2, ''' CREATE TABLE IF NOT EXISTS tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS link_tags ( link_id TEXT NOT NULL, tag_id TEXT NOT NULL, PRIMARY KEY (link_id, tag_id), FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE, FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id); '''), (3, ''' UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%'; '''), (4, ''' ALTER TABLE tags ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_tags_created_by ON tags(created_by); '''), ] def get_connection() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute('PRAGMA foreign_keys = ON') return conn def get_schema_version(conn: sqlite3.Connection) -> int: return conn.execute('PRAGMA user_version').fetchone()[0] def apply_migrations(conn: sqlite3.Connection) -> None: current_version = get_schema_version(conn) for version, sql in MIGRATIONS: if version <= current_version: continue conn.executescript(sql) conn.execute(f'PRAGMA user_version = {version}') conn.commit() def seed_default_tags(conn: sqlite3.Connection) -> None: for tag in DEFAULT_TAGS: conn.execute( 'INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)', (str(uuid4()), tag), ) def init_db() -> None: with get_connection() as conn: apply_migrations(conn) alice_hash = hash_password('secret123') bob_hash = hash_password('secret123') conn.execute( ''' INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, 1) ''', ('user-1', 'alice', 'alice@example.com', alice_hash) ) conn.execute("UPDATE users SET is_admin = 1 WHERE id = 'user-1'") conn.execute( ''' INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, 0) ''', ('user-2', 'bob', 'bob@example.com', bob_hash) ) conn.execute( ''' INSERT OR IGNORE INTO plugins (id, name, version, enabled, config) VALUES (?, ?, ?, 1, ?) ''', ('plugin-1', 'default_frontend', '1.0.0', '{"route": "/"}') ) conn.execute( ''' INSERT OR IGNORE INTO plugins (id, name, version, enabled, config) VALUES (?, ?, ?, 1, ?) ''', ('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}') ) seed_default_tags(conn) conn.commit()