Files
Link-Log/backend/app/database.py
T

246 lines
7.6 KiB
Python

## 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()