314 lines
10 KiB
Python
314 lines
10 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;
|
|
'''),
|
|
(12, '''
|
|
CREATE TABLE IF NOT EXISTS user_email_addresses (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE,
|
|
verified INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS email_address_verification_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
email_address_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(email_address_id) REFERENCES user_email_addresses(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_user_email_addresses_user_id ON user_email_addresses(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_email_address_verification_tokens_address_id ON email_address_verification_tokens(email_address_id);
|
|
'''),
|
|
(13, '''
|
|
CREATE TABLE IF NOT EXISTS pending_primary_email_changes (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL UNIQUE,
|
|
email TEXT NOT NULL UNIQUE,
|
|
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
|
|
);
|
|
'''),
|
|
(14, '''
|
|
DROP TABLE IF EXISTS pending_primary_email_changes;
|
|
'''),
|
|
(15, '''
|
|
ALTER TABLE tokens ADD COLUMN device_id TEXT;
|
|
ALTER TABLE tokens ADD COLUMN token_family_id TEXT;
|
|
CREATE INDEX IF NOT EXISTS idx_tokens_device_id ON tokens(device_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tokens_family_id ON tokens(token_family_id);
|
|
'''),
|
|
(16, '''
|
|
CREATE TABLE IF NOT EXISTS otp_recovery_codes (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
code_hash TEXT NOT NULL UNIQUE,
|
|
used INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
used_at TEXT,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_otp_recovery_codes_user_id ON otp_recovery_codes(user_id);
|
|
'''),
|
|
(17, '''
|
|
CREATE TABLE IF NOT EXISTS security_audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
actor_id TEXT,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT,
|
|
outcome TEXT NOT NULL DEFAULT 'success',
|
|
details TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(actor_id) REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_events_created_at ON security_audit_events(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_events_actor_id ON security_audit_events(actor_id);
|
|
''')
|
|
]
|
|
|
|
|
|
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()
|