#!/usr/bin/env python3 """ Database migration script - Adds missing columns for new features. Run this script to update an existing database without losing data. Usage: python migrate_db.py [database_path] If no path provided, uses the default /config/config.db """ import sqlite3 import sys from pathlib import Path def get_existing_columns(cursor, table_name): """Get list of existing column names for a table.""" cursor.execute(f"PRAGMA table_info({table_name})") return {row[1] for row in cursor.fetchall()} def get_existing_tables(cursor): """Get list of existing tables.""" cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") return {row[0] for row in cursor.fetchall()} def migrate(db_path): """Run migrations.""" print(f"Migrating database: {db_path}") conn = sqlite3.connect(db_path) cursor = conn.cursor() existing_tables = get_existing_tables(cursor) print(f"Existing tables: {existing_tables}") # ==================== # USERS TABLE MIGRATIONS # ==================== if "users" in existing_tables: existing_cols = get_existing_columns(cursor, "users") print(f"Users columns: {existing_cols}") # Add 2FA columns if "totp_secret" not in existing_cols: print(" Adding totp_secret column...") cursor.execute("ALTER TABLE users ADD COLUMN totp_secret VARCHAR(32)") if "totp_enabled" not in existing_cols: print(" Adding totp_enabled column...") cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN DEFAULT 0") if "totp_backup_codes" not in existing_cols: print(" Adding totp_backup_codes column...") cursor.execute("ALTER TABLE users ADD COLUMN totp_backup_codes TEXT") # ==================== # CREATE NEW TABLES IF MISSING # ==================== # Audit Logs if "audit_logs" not in existing_tables: print("Creating audit_logs table...") cursor.execute(""" CREATE TABLE audit_logs ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), username VARCHAR(100), action VARCHAR(50) NOT NULL, resource_type VARCHAR(50), resource_id VARCHAR(255), details TEXT, ip_address VARCHAR(45), user_agent VARCHAR(500), status VARCHAR(20) DEFAULT 'success', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) """) cursor.execute("CREATE INDEX ix_audit_logs_action ON audit_logs(action)") cursor.execute("CREATE INDEX ix_audit_logs_resource_type ON audit_logs(resource_type)") cursor.execute("CREATE INDEX ix_audit_logs_created_at ON audit_logs(created_at)") # API Keys if "api_keys" not in existing_tables: print("Creating api_keys table...") cursor.execute(""" CREATE TABLE api_keys ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, name VARCHAR(100) NOT NULL, key_hash VARCHAR(64) NOT NULL UNIQUE, key_prefix VARCHAR(8) NOT NULL, scopes TEXT, is_active BOOLEAN DEFAULT 1, expires_at DATETIME, last_used_at DATETIME, last_used_ip VARCHAR(45), usage_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """) cursor.execute("CREATE INDEX ix_api_keys_key_hash ON api_keys(key_hash)") # Notifications if "notifications" not in existing_tables: print("Creating notifications table...") cursor.execute(""" CREATE TABLE notifications ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, message TEXT NOT NULL, type VARCHAR(20) DEFAULT 'info', link VARCHAR(500), extra_data TEXT, is_read BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, read_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """) cursor.execute("CREATE INDEX ix_notifications_user_id ON notifications(user_id)") cursor.execute("CREATE INDEX ix_notifications_is_read ON notifications(is_read)") # User Sessions if "user_sessions" not in existing_tables: print("Creating user_sessions table...") cursor.execute(""" CREATE TABLE user_sessions ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, token_hash VARCHAR(64) NOT NULL UNIQUE, device_name VARCHAR(200), device_type VARCHAR(50), browser VARCHAR(100), os VARCHAR(100), user_agent VARCHAR(500), ip_address VARCHAR(45), location VARCHAR(200), is_active BOOLEAN DEFAULT 1, is_current BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME, revoked_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """) cursor.execute("CREATE INDEX ix_user_sessions_token_hash ON user_sessions(token_hash)") cursor.execute("CREATE INDEX ix_user_sessions_user_id ON user_sessions(user_id)") # Webhooks if "webhooks" not in existing_tables: print("Creating webhooks table...") cursor.execute(""" CREATE TABLE webhooks ( id VARCHAR(36) PRIMARY KEY, name VARCHAR(100) NOT NULL, url VARCHAR(500) NOT NULL, secret VARCHAR(64), events TEXT DEFAULT '["*"]', is_active BOOLEAN DEFAULT 1, retry_count INTEGER DEFAULT 3, timeout_seconds INTEGER DEFAULT 30, created_by VARCHAR(36), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_triggered_at DATETIME, success_count INTEGER DEFAULT 0, failure_count INTEGER DEFAULT 0, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) """) # Webhook Deliveries if "webhook_deliveries" not in existing_tables: print("Creating webhook_deliveries table...") cursor.execute(""" CREATE TABLE webhook_deliveries ( id VARCHAR(36) PRIMARY KEY, webhook_id VARCHAR(36) NOT NULL, event_type VARCHAR(50) NOT NULL, payload TEXT NOT NULL, status VARCHAR(20) DEFAULT 'pending', status_code INTEGER, response_body TEXT, error_message TEXT, attempt_count INTEGER DEFAULT 0, next_retry_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, delivered_at DATETIME, FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ) """) # Stored Files if "stored_files" not in existing_tables: print("Creating stored_files table...") cursor.execute(""" CREATE TABLE stored_files ( id VARCHAR(36) PRIMARY KEY, original_filename VARCHAR(255) NOT NULL, content_type VARCHAR(100), size_bytes BIGINT NOT NULL, storage_path VARCHAR(500) NOT NULL, storage_type VARCHAR(20) DEFAULT 'local', description TEXT, tags TEXT, is_public BOOLEAN DEFAULT 0, uploaded_by VARCHAR(36), file_hash VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_deleted BOOLEAN DEFAULT 0, deleted_at DATETIME, FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL ) """) cursor.execute("CREATE INDEX ix_stored_files_file_hash ON stored_files(file_hash)") conn.commit() conn.close() print("Migration completed successfully!") if __name__ == "__main__": # Default path for Docker container default_path = "/config/config.db" if len(sys.argv) > 1: db_path = sys.argv[1] else: # Check common locations paths_to_try = [ Path(default_path), Path("./config.db"), Path("./data/config.db"), Path("../config/config.db"), ] db_path = None for p in paths_to_try: if p.exists(): db_path = str(p) break if not db_path: print(f"Database not found. Please provide path as argument.") print(f"Usage: python migrate_db.py /path/to/config.db") sys.exit(1) if not Path(db_path).exists(): print(f"Error: Database file not found: {db_path}") sys.exit(1) migrate(db_path)