## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import sqlite3 import os from hashlib import scrypt, sha256 import hmac 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: salt = os.urandom(16) digest = scrypt(password.encode('utf-8'), salt=salt, n=16_384, r=8, p=1, dklen=32) return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}' def verify_password(password: str, stored_hash: str) -> bool: if stored_hash.startswith('scrypt$'): try: algorithm, cost, block_size, parallelism, salt_hex, digest_hex = stored_hash.split('$') if algorithm != 'scrypt': return False digest = scrypt( password.encode('utf-8'), salt=bytes.fromhex(salt_hex), n=int(cost), r=int(block_size), p=int(parallelism), dklen=32, ) return hmac.compare_digest(digest.hex(), digest_hex) except (ValueError, TypeError): return False if len(stored_hash) == 64: legacy_digest = sha256(password.encode('utf-8')).hexdigest() return hmac.compare_digest(legacy_digest, stored_hash) return False 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); '''), (5, ''' ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0; UPDATE users SET email_verified = 1; CREATE TABLE IF NOT EXISTS email_verification_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); '''), (6, ''' CREATE TABLE IF NOT EXISTS app_settings ( name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); '''), (7, ''' CREATE TABLE IF NOT EXISTS password_reset_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); '''), (8, ''' CREATE TABLE IF NOT EXISTS mastodon_oauth_states ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state_hash TEXT NOT NULL UNIQUE, instance TEXT NOT NULL, client_id TEXT NOT NULL, client_secret TEXT NOT NULL, redirect_uri TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash ON mastodon_oauth_states(state_hash); '''), (9, ''' ALTER TABLE links ADD COLUMN mastodon_posted INTEGER NOT NULL DEFAULT 0; ALTER TABLE links ADD COLUMN mastodon_post_id TEXT; ALTER TABLE links ADD COLUMN mastodon_posted_at TEXT; '''), (10, ''' ALTER TABLE links ADD COLUMN mastodon_post_ids TEXT; UPDATE links SET mastodon_post_ids = CASE WHEN mastodon_post_id IS NOT NULL THEN json_array(mastodon_post_id) ELSE '[]' END WHERE mastodon_post_ids IS NULL; '''), (11, ''' ALTER TABLE users ADD COLUMN otp_secret TEXT; ALTER TABLE users ADD COLUMN otp_enabled INTEGER NOT NULL DEFAULT 0; '''), ] 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) 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()