"""YakPanel - Database configuration""" import os import sqlite3 from pathlib import Path from sqlalchemy.engine.url import make_url from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from app.core.config import get_settings def _resolve_database_url(raw_url: str) -> str: """Anchor relative SQLite paths to the backend directory (not process CWD).""" url = make_url(raw_url) if not url.drivername.startswith("sqlite"): return raw_url db_path = url.database if db_path is None or db_path.startswith(":"): return raw_url path = Path(db_path) backend_dir = Path(__file__).resolve().parent.parent if not path.is_absolute(): path = (backend_dir / path).resolve() else: path = path.resolve() data_dir = path.parent data_dir.mkdir(parents=True, exist_ok=True) try: os.chmod(data_dir, 0o755) except OSError: pass if not os.access(data_dir, os.W_OK): raise RuntimeError( f"SQLite directory not writable: {data_dir} " f"(fix ownership or permissions; check SELinux if enabled)." ) abs_path = path.as_posix() try: test = sqlite3.connect(abs_path) test.close() except sqlite3.Error as e: raise RuntimeError( f"SQLite refused to open {abs_path} (from DATABASE_URL). {e}. " f"Parent dir: {data_dir} writable={os.access(data_dir, os.W_OK)}." ) from e # Unix absolute file: sqlite+aiosqlite:////abs/path (four slashes total after the colon) return f"sqlite+aiosqlite:///{abs_path}" settings = get_settings() DATABASE_URL = _resolve_database_url(settings.database_url) _engine_kw = {"echo": settings.debug} if make_url(DATABASE_URL).drivername.startswith("sqlite"): _engine_kw["connect_args"] = {"timeout": 30} engine = create_async_engine( DATABASE_URL, **_engine_kw, ) AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False, ) class Base(DeclarativeBase): """SQLAlchemy declarative base""" pass async def get_db(): """Dependency for async database sessions""" async with AsyncSessionLocal() as session: try: yield session await session.commit() except Exception: await session.rollback() raise finally: await session.close() def _run_migrations(conn): """Add new columns to existing tables (SQLite).""" import sqlalchemy try: r = conn.execute(sqlalchemy.text("PRAGMA table_info(sites)")) cols = [row[1] for row in r.fetchall()] if "php_version" not in cols: conn.execute( sqlalchemy.text( "ALTER TABLE sites ADD COLUMN php_version VARCHAR(16) DEFAULT '74'" ) ) if "force_https" not in cols: conn.execute( sqlalchemy.text( "ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0" ) ) except Exception: pass # Create backup_plans if not exists (create_all handles new installs) try: conn.execute(sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS backup_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(128) NOT NULL, plan_type VARCHAR(32) NOT NULL, target_id INTEGER NOT NULL, schedule VARCHAR(64) NOT NULL, enabled BOOLEAN DEFAULT 1 ) """)) except Exception: pass # Create custom_plugins if not exists try: conn.execute(sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS custom_plugins ( id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id VARCHAR(64) UNIQUE NOT NULL, name VARCHAR(128) NOT NULL, version VARCHAR(32) DEFAULT '1.0', desc VARCHAR(512) DEFAULT '', source_url VARCHAR(512) DEFAULT '', enabled BOOLEAN DEFAULT 1 ) """)) except Exception: pass async def init_db(): """Initialize database tables""" import app.models # noqa: F401 - register all models with Base.metadata async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) if "sqlite" in str(engine.url): await conn.run_sync(_run_migrations)