949 lines
36 KiB
Python
949 lines
36 KiB
Python
import os
|
|
import sqlite3
|
|
import time
|
|
from typing import Optional
|
|
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from config import (
|
|
DB_PATH,
|
|
UPLOAD_DIR,
|
|
ROLES,
|
|
STATUSES,
|
|
now_iso,
|
|
)
|
|
|
|
|
|
def get_db() -> sqlite3.Connection:
|
|
db_dir = os.path.dirname(DB_PATH)
|
|
if db_dir:
|
|
os.makedirs(db_dir, exist_ok=True)
|
|
|
|
conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
conn.execute("PRAGMA busy_timeout = 8000")
|
|
conn.execute("PRAGMA synchronous = NORMAL")
|
|
return conn
|
|
|
|
|
|
def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
|
|
last_error = None
|
|
for attempt in range(5):
|
|
try:
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
cursor = db.execute(query, params)
|
|
db.commit()
|
|
return cursor
|
|
except sqlite3.OperationalError as error:
|
|
last_error = error
|
|
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
|
|
raise
|
|
if attempt == 4:
|
|
raise
|
|
time.sleep(0.05 * (2 ** attempt))
|
|
raise last_error
|
|
|
|
|
|
def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool:
|
|
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
|
|
return any(row["name"] == column for row in rows)
|
|
|
|
|
|
def init_db() -> None:
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
with get_db() as db:
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
restricted_until TEXT,
|
|
restriction_reason TEXT,
|
|
created_at TEXT NOT NULL,
|
|
last_login_at TEXT
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS rooms (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
visibility TEXT NOT NULL DEFAULT 'public',
|
|
created_by_user_id INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS room_members (
|
|
room_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (room_id, user_id),
|
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
uploader_user_id INTEGER NOT NULL,
|
|
stored_name TEXT UNIQUE NOT NULL,
|
|
original_name TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS teams (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
created_by_user_id INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS team_members (
|
|
team_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (team_id, user_id),
|
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS team_invites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
team_id INTEGER NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
created_by_user_id INTEGER NOT NULL,
|
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
used_by_user_id INTEGER,
|
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS channels (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
team_id INTEGER NOT NULL,
|
|
legacy_room_id INTEGER UNIQUE,
|
|
slug TEXT COLLATE NOCASE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
created_by_user_id INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
UNIQUE(team_id, slug),
|
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS direct_conversations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
member_pair_key TEXT UNIQUE NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS direct_conversation_members (
|
|
direct_conversation_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (direct_conversation_id, user_id),
|
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
room_id INTEGER,
|
|
channel_id INTEGER,
|
|
direct_conversation_id INTEGER,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
attachment_id INTEGER,
|
|
parent_message_id INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS room_invites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
room_id INTEGER NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
created_by_user_id INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
used_by_user_id INTEGER,
|
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS typing_events (
|
|
room_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (room_id, user_id),
|
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS channel_typing_events (
|
|
channel_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (channel_id, user_id),
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS dm_typing_events (
|
|
direct_conversation_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (direct_conversation_id, user_id),
|
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS blacklist_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL,
|
|
value_hash TEXT NOT NULL,
|
|
reason TEXT,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT,
|
|
created_by_user_id INTEGER,
|
|
UNIQUE(kind, value_hash),
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS rate_limit_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
scope TEXT NOT NULL,
|
|
subject_hash TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
actor_user_id INTEGER,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
details TEXT,
|
|
client_hash TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
team_id INTEGER NOT NULL,
|
|
channel_id INTEGER,
|
|
slug TEXT COLLATE NOCASE NOT NULL,
|
|
title TEXT NOT NULL,
|
|
body TEXT NOT NULL DEFAULT '',
|
|
visibility TEXT NOT NULL DEFAULT 'team',
|
|
page_type TEXT NOT NULL DEFAULT 'document',
|
|
created_by_user_id INTEGER NOT NULL,
|
|
updated_by_user_id INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
UNIQUE(team_id, slug),
|
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
|
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
|
user_id INTEGER PRIMARY KEY,
|
|
display_name TEXT,
|
|
badge_text TEXT,
|
|
bio TEXT NOT NULL DEFAULT '',
|
|
bio_mode TEXT NOT NULL DEFAULT 'txt',
|
|
website TEXT,
|
|
location TEXT,
|
|
status_message TEXT NOT NULL DEFAULT '',
|
|
banner_attachment_id INTEGER,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
user_id INTEGER PRIMARY KEY,
|
|
accent_color TEXT,
|
|
name_color TEXT,
|
|
custom_css TEXT NOT NULL DEFAULT '',
|
|
theme_mode TEXT NOT NULL DEFAULT 'dark',
|
|
profile_background_color TEXT,
|
|
profile_banner_color TEXT,
|
|
profile_visibility TEXT NOT NULL DEFAULT 'public',
|
|
last_active_visibility TEXT NOT NULL DEFAULT 'everyone',
|
|
presence_mode TEXT NOT NULL DEFAULT 'online',
|
|
dm_layout_mode TEXT NOT NULL DEFAULT 'cards',
|
|
show_presence_badge INTEGER NOT NULL DEFAULT 1,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
|
message_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
emoji TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (message_id, user_id, emoji),
|
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS message_edits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
message_id INTEGER NOT NULL,
|
|
edited_by_user_id INTEGER NOT NULL,
|
|
previous_text TEXT NOT NULL,
|
|
new_text TEXT NOT NULL,
|
|
previous_attachments_json TEXT NOT NULL DEFAULT '[]',
|
|
new_attachments_json TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS message_receipts (
|
|
message_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
seen_at TEXT NOT NULL,
|
|
PRIMARY KEY (message_id, user_id),
|
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS message_attachments (
|
|
message_id INTEGER NOT NULL,
|
|
attachment_id INTEGER NOT NULL,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (message_id, attachment_id),
|
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS presence_events (
|
|
user_id INTEGER PRIMARY KEY,
|
|
updated_at REAL NOT NULL,
|
|
view_name TEXT,
|
|
room_id INTEGER,
|
|
channel_id INTEGER,
|
|
direct_conversation_id INTEGER,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS call_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
direct_conversation_id INTEGER NOT NULL,
|
|
started_by_user_id INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS call_signals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
call_session_id INTEGER NOT NULL,
|
|
from_user_id INTEGER NOT NULL,
|
|
signal_type TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS group_call_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
scope_kind TEXT NOT NULL,
|
|
scope_id INTEGER NOT NULL,
|
|
scope_slug TEXT NOT NULL,
|
|
helper_call_id TEXT NOT NULL UNIQUE,
|
|
created_by_user_id INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS group_call_invites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
group_call_session_id INTEGER NOT NULL,
|
|
inviter_user_id INTEGER NOT NULL,
|
|
invitee_user_id INTEGER NOT NULL,
|
|
helper_token TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
responded_at TEXT,
|
|
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS group_call_participants (
|
|
group_call_session_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
joined_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
left_at TEXT,
|
|
PRIMARY KEY (group_call_session_id, user_id),
|
|
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
"""
|
|
)
|
|
|
|
if not column_exists(db, "messages", "room_id"):
|
|
db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER")
|
|
if not column_exists(db, "messages", "attachment_id"):
|
|
db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER")
|
|
if not column_exists(db, "messages", "parent_message_id"):
|
|
db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
|
|
if not column_exists(db, "messages", "channel_id"):
|
|
db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER")
|
|
if not column_exists(db, "messages", "direct_conversation_id"):
|
|
db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER")
|
|
|
|
if not column_exists(db, "user_profiles", "badge_text"):
|
|
db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT")
|
|
if not column_exists(db, "user_profiles", "status_message"):
|
|
db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''")
|
|
if not column_exists(db, "user_profiles", "banner_attachment_id"):
|
|
db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER")
|
|
|
|
if not column_exists(db, "user_settings", "name_color"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
|
|
if not column_exists(db, "user_settings", "profile_background_color"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT")
|
|
if not column_exists(db, "user_settings", "profile_banner_color"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT")
|
|
if not column_exists(db, "user_settings", "profile_visibility"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'")
|
|
if not column_exists(db, "user_settings", "last_active_visibility"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'")
|
|
if not column_exists(db, "user_settings", "presence_mode"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'")
|
|
if not column_exists(db, "user_settings", "dm_layout_mode"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'")
|
|
if not column_exists(db, "user_settings", "show_presence_badge"):
|
|
db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1")
|
|
|
|
if not column_exists(db, "presence_events", "direct_conversation_id"):
|
|
db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER")
|
|
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)")
|
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)")
|
|
|
|
general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
|
|
if not general:
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO rooms (
|
|
slug,
|
|
name,
|
|
description,
|
|
visibility,
|
|
created_at,
|
|
is_active
|
|
) VALUES (?, ?, ?, ?, ?, 1)
|
|
""",
|
|
("general", "general", "Default public chat room", "public", now_iso()),
|
|
)
|
|
general_id = cursor.lastrowid
|
|
else:
|
|
general_id = general["id"]
|
|
|
|
db.execute(
|
|
"UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL",
|
|
(general_id,),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
UPDATE users
|
|
SET role = CASE
|
|
WHEN is_admin = 1 THEN 'admin'
|
|
ELSE 'user'
|
|
END
|
|
WHERE role IS NULL
|
|
OR role = ''
|
|
"""
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
UPDATE users
|
|
SET status = CASE
|
|
WHEN is_active = 1 THEN 'active'
|
|
ELSE 'disabled'
|
|
END
|
|
WHERE status IS NULL
|
|
OR status = ''
|
|
"""
|
|
)
|
|
|
|
general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone()
|
|
if general_team:
|
|
general_team_id = general_team["id"]
|
|
else:
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO teams (
|
|
slug,
|
|
name,
|
|
description,
|
|
created_by_user_id,
|
|
created_at,
|
|
is_active
|
|
) VALUES (?, ?, ?, NULL, ?, 1)
|
|
""",
|
|
("general", "General", "Default team for existing chat rooms.", now_iso()),
|
|
)
|
|
general_team_id = cursor.lastrowid
|
|
|
|
legacy_rooms = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
slug,
|
|
name,
|
|
description,
|
|
created_by_user_id,
|
|
created_at
|
|
FROM rooms
|
|
WHERE is_active = 1
|
|
"""
|
|
).fetchall()
|
|
|
|
for room in legacy_rooms:
|
|
existing_channel = db.execute(
|
|
"SELECT id FROM channels WHERE legacy_room_id = ?",
|
|
(room["id"],),
|
|
).fetchone()
|
|
if existing_channel:
|
|
continue
|
|
db.execute(
|
|
"""
|
|
INSERT INTO channels (
|
|
team_id,
|
|
legacy_room_id,
|
|
slug,
|
|
name,
|
|
description,
|
|
created_by_user_id,
|
|
created_at,
|
|
is_active
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
""",
|
|
(
|
|
general_team_id,
|
|
room["id"],
|
|
room["slug"],
|
|
room["name"],
|
|
room["description"],
|
|
room["created_by_user_id"],
|
|
room["created_at"],
|
|
),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
UPDATE messages
|
|
SET channel_id = (
|
|
SELECT c.id
|
|
FROM channels AS c
|
|
WHERE c.legacy_room_id = messages.room_id
|
|
LIMIT 1
|
|
)
|
|
WHERE channel_id IS NULL
|
|
AND room_id IS NOT NULL
|
|
AND direct_conversation_id IS NULL
|
|
"""
|
|
)
|
|
|
|
admin_users = db.execute(
|
|
"""
|
|
SELECT id
|
|
FROM users
|
|
WHERE role = 'admin'
|
|
AND status = 'active'
|
|
"""
|
|
).fetchall()
|
|
|
|
for admin_user in admin_users:
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO team_members (
|
|
team_id,
|
|
user_id,
|
|
membership_role,
|
|
added_at
|
|
) VALUES (?, ?, 'admin', ?)
|
|
""",
|
|
(general_team_id, admin_user["id"], now_iso()),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO user_profiles (
|
|
user_id,
|
|
display_name,
|
|
badge_text,
|
|
bio,
|
|
bio_mode,
|
|
website,
|
|
location,
|
|
status_message,
|
|
banner_attachment_id,
|
|
updated_at
|
|
)
|
|
SELECT
|
|
id,
|
|
username,
|
|
'',
|
|
'',
|
|
'txt',
|
|
'',
|
|
'',
|
|
'',
|
|
NULL,
|
|
?
|
|
FROM users
|
|
""",
|
|
(now_iso(),),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO user_settings (
|
|
user_id,
|
|
accent_color,
|
|
name_color,
|
|
custom_css,
|
|
theme_mode,
|
|
profile_background_color,
|
|
profile_banner_color,
|
|
profile_visibility,
|
|
last_active_visibility,
|
|
presence_mode,
|
|
dm_layout_mode,
|
|
show_presence_badge,
|
|
updated_at
|
|
)
|
|
SELECT
|
|
id,
|
|
'#f7dce8',
|
|
NULL,
|
|
'',
|
|
'dark',
|
|
NULL,
|
|
NULL,
|
|
'public',
|
|
'everyone',
|
|
'online',
|
|
'cards',
|
|
1,
|
|
?
|
|
FROM users
|
|
""",
|
|
(now_iso(),),
|
|
)
|
|
|
|
existing_message_attachment_counts = db.execute(
|
|
"SELECT COUNT(*) AS count FROM message_attachments"
|
|
).fetchone()["count"]
|
|
|
|
if existing_message_attachment_counts == 0:
|
|
rows = db.execute(
|
|
"""
|
|
SELECT id, attachment_id
|
|
FROM messages
|
|
WHERE attachment_id IS NOT NULL
|
|
ORDER BY id ASC
|
|
"""
|
|
).fetchall()
|
|
for row in rows:
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO message_attachments (
|
|
message_id,
|
|
attachment_id,
|
|
position
|
|
) VALUES (?, ?, 0)
|
|
""",
|
|
(row["id"], row["attachment_id"]),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
|
|
def bootstrap_admin() -> None:
|
|
username = os.environ.get("ADMIN_USERNAME", "").strip().lower()
|
|
password = os.environ.get("ADMIN_PASSWORD", "")
|
|
|
|
if not username or not password:
|
|
return
|
|
|
|
with get_db() as db:
|
|
existing = db.execute(
|
|
"SELECT id FROM users WHERE username = ?",
|
|
(username,),
|
|
).fetchone()
|
|
|
|
if existing:
|
|
db.execute(
|
|
"""
|
|
UPDATE users
|
|
SET
|
|
role = 'admin',
|
|
status = 'active',
|
|
is_admin = 1,
|
|
is_active = 1
|
|
WHERE id = ?
|
|
""",
|
|
(existing["id"],),
|
|
)
|
|
user_id = existing["id"]
|
|
else:
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
username,
|
|
password_hash,
|
|
role,
|
|
status,
|
|
is_admin,
|
|
is_active,
|
|
created_at
|
|
) VALUES (?, ?, 'admin', 'active', 1, 1, ?)
|
|
""",
|
|
(username, generate_password_hash(password), now_iso()),
|
|
)
|
|
user_id = cursor.lastrowid
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO user_profiles (
|
|
user_id,
|
|
display_name,
|
|
badge_text,
|
|
bio,
|
|
bio_mode,
|
|
website,
|
|
location,
|
|
status_message,
|
|
banner_attachment_id,
|
|
updated_at
|
|
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
|
""",
|
|
(user_id, username, now_iso()),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO user_settings (
|
|
user_id,
|
|
accent_color,
|
|
name_color,
|
|
custom_css,
|
|
theme_mode,
|
|
profile_background_color,
|
|
profile_banner_color,
|
|
profile_visibility,
|
|
last_active_visibility,
|
|
presence_mode,
|
|
dm_layout_mode,
|
|
show_presence_badge,
|
|
updated_at
|
|
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
|
""",
|
|
(user_id, now_iso()),
|
|
)
|
|
|
|
db.commit()
|