From fbc445dad22804dc0ad45f21d8d7798ec8d82531 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Tue, 9 Jun 2026 14:52:02 +0000 Subject: [PATCH] Migrate panel DB from MySQL to SQLite Panel no longer depends on the user-managed MariaDB service. SQLite at /var/lib/novacpx/panel.db runs independently so the control panel stays up even when MariaDB is stopped. - DB.php: switch to sqlite: DSN, add SQL translator (ON DUPLICATE KEY, DATE_ADD/DATE_SUB INTERVAL, NOW(), UNIX_TIMESTAMP(), IFNULL) - Core.php: replace DB_HOST/NAME/USER/PASS with DB_PATH constant - schema.sql: full SQLite syntax, add TOTP columns to users table - _branding.php: use sqlite: PDO, datetime('now') for session check - install.sh: apt install sqlite3, create SQLite DB instead of MySQL DB - tools/migrate-to-sqlite.sh: one-shot migration script for existing installs --- db/schema.sql | 774 +++++++++++++++++++++---------------- panel/lib/Core.php | 7 +- panel/lib/DB.php | 86 ++++- panel/public/_branding.php | 6 +- tools/migrate-to-sqlite.sh | 175 +++++++++ 5 files changed, 713 insertions(+), 335 deletions(-) create mode 100755 tools/migrate-to-sqlite.sh diff --git a/db/schema.sql b/db/schema.sql index 41d3998..714755f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,405 +1,531 @@ --- NovaCPX Database Schema v1.0.0 --- Engine: MySQL 8+ | Charset: utf8mb4_unicode_ci +-- NovaCPX Database Schema v1.1.0 +-- Engine: SQLite 3.35+ -SET NAMES utf8mb4; -SET foreign_key_checks = 0; +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = OFF; -- ── Version tracking ────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS novacpx_version ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - version VARCHAR(20) NOT NULL, - installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL, + installed_at TEXT DEFAULT (datetime('now')), notes TEXT, - git_commit VARCHAR(64), - INDEX idx_version (version) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + git_commit TEXT +); -INSERT INTO novacpx_version (version, notes, git_commit) -VALUES ('1.0.0', 'Initial installation', 'HEAD'); +INSERT OR IGNORE INTO novacpx_version (version, notes, git_commit) +VALUES ('1.1.0', 'Initial installation', 'HEAD'); --- ── Audit log (every action tracked) ───────────────────────────────────────── +-- ── Audit log ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS audit_log ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED, - username VARCHAR(100), - action VARCHAR(100) NOT NULL, - resource VARCHAR(200), - detail JSON, - ip_address VARCHAR(45), + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT, + action TEXT NOT NULL, + resource TEXT, + detail TEXT, + ip_address TEXT, user_agent TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_user (user_id), - INDEX idx_action (action), - INDEX idx_created (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log (user_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log (created_at); --- ── Users (admin, resellers, end-users) ─────────────────────────────────────── +-- ── Users ───────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS users ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(100) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - role ENUM('admin','reseller','user') DEFAULT 'user', - status ENUM('active','suspended','pending') DEFAULT 'active', - reseller_id INT UNSIGNED DEFAULT NULL, - package_id INT UNSIGNED DEFAULT NULL, - theme VARCHAR(50) DEFAULT 'nova-dark', - language VARCHAR(10) DEFAULT 'en', - contact_name VARCHAR(200), - contact_phone VARCHAR(50), - last_login DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (reseller_id) REFERENCES users(id) ON DELETE SET NULL, - INDEX idx_role (role), - INDEX idx_status (status), - INDEX idx_reseller (reseller_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','reseller','user')), + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended','pending')), + reseller_id INTEGER DEFAULT NULL, + package_id INTEGER DEFAULT NULL, + theme TEXT DEFAULT 'nova-dark', + language TEXT DEFAULT 'en', + contact_name TEXT, + contact_phone TEXT, + last_login TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + totp_secret TEXT, + totp_enabled INTEGER DEFAULT 0, + totp_backup_codes TEXT, + FOREIGN KEY (reseller_id) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_users_role ON users (role); +CREATE INDEX IF NOT EXISTS idx_users_status ON users (status); +CREATE INDEX IF NOT EXISTS idx_users_reseller ON users (reseller_id); -- ── Sessions ────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS sessions ( - id VARCHAR(128) PRIMARY KEY, - user_id INT UNSIGNED NOT NULL, - ip_address VARCHAR(45), + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + ip_address TEXT, user_agent TEXT, - data JSON, - expires_at DATETIME NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - INDEX idx_expires (expires_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + data TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at); -- ── Packages / Hosting Plans ────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS packages ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - owner_id INT UNSIGNED DEFAULT NULL, -- NULL = system, or reseller - disk_mb INT UNSIGNED DEFAULT 1024, - bandwidth_mb BIGINT UNSIGNED DEFAULT 10240, - max_domains SMALLINT UNSIGNED DEFAULT 1, - max_subdomains SMALLINT UNSIGNED DEFAULT 10, - max_addon_domains SMALLINT UNSIGNED DEFAULT 0, - max_parked_domains SMALLINT UNSIGNED DEFAULT 5, - max_email SMALLINT UNSIGNED DEFAULT 10, - max_ftp SMALLINT UNSIGNED DEFAULT 5, - max_databases SMALLINT UNSIGNED DEFAULT 5, - php_version VARCHAR(10) DEFAULT '8.3', - ssl_enabled TINYINT(1) DEFAULT 1, - is_default TINYINT(1) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL, - INDEX idx_owner (owner_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + owner_id INTEGER DEFAULT NULL, + disk_mb INTEGER DEFAULT 1024, + bandwidth_mb INTEGER DEFAULT 10240, + max_domains INTEGER DEFAULT 1, + max_subdomains INTEGER DEFAULT 10, + max_addon_domains INTEGER DEFAULT 0, + max_parked_domains INTEGER DEFAULT 5, + max_email INTEGER DEFAULT 10, + max_ftp INTEGER DEFAULT 5, + max_databases INTEGER DEFAULT 5, + php_version TEXT DEFAULT '8.3', + ssl_enabled INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_packages_owner ON packages (owner_id); -INSERT INTO packages (name, disk_mb, bandwidth_mb, max_domains, max_email, max_databases, is_default) -VALUES ('Default', 5120, 51200, 5, 25, 10, 1); +INSERT OR IGNORE INTO packages (id, name, disk_mb, bandwidth_mb, max_domains, max_email, max_databases, is_default) +VALUES (1, 'Default', 5120, 51200, 5, 25, 10, 1); -- ── Hosting Accounts ────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS accounts ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED NOT NULL UNIQUE, - username VARCHAR(32) NOT NULL UNIQUE, - domain VARCHAR(253) NOT NULL, - home_dir VARCHAR(500) NOT NULL, - package_id INT UNSIGNED, - disk_used_mb INT UNSIGNED DEFAULT 0, - bw_used_mb BIGINT UNSIGNED DEFAULT 0, - php_version VARCHAR(10) DEFAULT '8.3', - web_server ENUM('apache','nginx') DEFAULT 'apache', - status ENUM('active','suspended','terminated') DEFAULT 'active', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - suspended_at DATETIME DEFAULT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL, - INDEX idx_domain (domain), - INDEX idx_status (status) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + domain TEXT NOT NULL, + home_dir TEXT NOT NULL, + document_root TEXT, + system_user TEXT DEFAULT 'www-data', + package_id INTEGER, + disk_used_mb INTEGER DEFAULT 0, + bw_used_mb INTEGER DEFAULT 0, + php_version TEXT DEFAULT '8.3', + web_server TEXT DEFAULT 'apache' CHECK(web_server IN ('apache','nginx')), + status TEXT DEFAULT 'active' CHECK(status IN ('active','suspended','terminated')), + cf_api_key TEXT, + cf_api_email TEXT, + cf_zone_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + suspended_at TEXT DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_accounts_domain ON accounts (domain); +CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status); -- ── Domains ─────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS domains ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - domain VARCHAR(253) NOT NULL, - type ENUM('main','addon','subdomain','parked','alias') DEFAULT 'main', - document_root VARCHAR(500), - php_version VARCHAR(10), - ssl_enabled TINYINT(1) DEFAULT 0, - ssl_cert TEXT, - ssl_key TEXT, - ssl_chain TEXT, - ssl_expires DATE, - redirect_to VARCHAR(500) DEFAULT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - UNIQUE KEY uq_domain (domain), - INDEX idx_account (account_id), - INDEX idx_type (type) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + domain TEXT NOT NULL UNIQUE, + type TEXT DEFAULT 'main' CHECK(type IN ('main','addon','subdomain','parked','alias')), + document_root TEXT, + php_version TEXT, + ssl_enabled INTEGER DEFAULT 0, + ssl_cert TEXT, + ssl_key TEXT, + ssl_chain TEXT, + ssl_expires TEXT, + redirect_to TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_domains_account ON domains (account_id); +CREATE INDEX IF NOT EXISTS idx_domains_type ON domains (type); -- ── DNS Zones & Records ─────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS dns_zones ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - domain VARCHAR(253) NOT NULL UNIQUE, - serial BIGINT UNSIGNED DEFAULT 1, - primary_ns VARCHAR(253) DEFAULT 'ns1.localhost', - secondary_ns VARCHAR(253) DEFAULT 'ns2.localhost', - admin_email VARCHAR(255) DEFAULT 'hostmaster@localhost', - ttl INT UNSIGNED DEFAULT 3600, - refresh INT UNSIGNED DEFAULT 86400, - retry INT UNSIGNED DEFAULT 7200, - expire INT UNSIGNED DEFAULT 2419200, - minimum INT UNSIGNED DEFAULT 86400, - status ENUM('active','disabled') DEFAULT 'active', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - INDEX idx_account (account_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + domain TEXT NOT NULL UNIQUE, + serial INTEGER DEFAULT 1, + primary_ns TEXT DEFAULT 'ns1.localhost', + secondary_ns TEXT DEFAULT 'ns2.localhost', + admin_email TEXT DEFAULT 'hostmaster@localhost', + ttl INTEGER DEFAULT 3600, + refresh INTEGER DEFAULT 86400, + retry INTEGER DEFAULT 7200, + expire INTEGER DEFAULT 2419200, + minimum INTEGER DEFAULT 86400, + status TEXT DEFAULT 'active' CHECK(status IN ('active','disabled')), + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_dns_zones_account ON dns_zones (account_id); CREATE TABLE IF NOT EXISTS dns_records ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - zone_id INT UNSIGNED NOT NULL, - name VARCHAR(253) NOT NULL, - type ENUM('A','AAAA','CNAME','MX','TXT','SRV','NS','PTR','CAA','DKIM','SPF','DMARC') NOT NULL, + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('A','AAAA','CNAME','MX','TXT','SRV','NS','PTR','CAA','DKIM','SPF','DMARC')), content TEXT NOT NULL, - ttl INT UNSIGNED DEFAULT 3600, - priority SMALLINT UNSIGNED DEFAULT NULL, - proxied TINYINT(1) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (zone_id) REFERENCES dns_zones(id) ON DELETE CASCADE, - INDEX idx_zone (zone_id), - INDEX idx_type (type), - INDEX idx_name (name(100)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + ttl INTEGER DEFAULT 3600, + priority INTEGER DEFAULT NULL, + proxied INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (zone_id) REFERENCES dns_zones(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_dns_records_zone ON dns_records (zone_id); +CREATE INDEX IF NOT EXISTS idx_dns_records_type ON dns_records (type); --- ── Email Accounts & Forwarders ─────────────────────────────────────────────── +-- ── Email ───────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS email_accounts ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - quota_mb INT UNSIGNED DEFAULT 500, - used_mb INT UNSIGNED DEFAULT 0, - status ENUM('active','suspended') DEFAULT 'active', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - INDEX idx_account (account_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + quota_mb INTEGER DEFAULT 500, + used_mb INTEGER DEFAULT 0, + status TEXT DEFAULT 'active' CHECK(status IN ('active','suspended')), + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_email_accounts_account ON email_accounts (account_id); CREATE TABLE IF NOT EXISTS email_forwarders ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - source VARCHAR(320) NOT NULL, - destination VARCHAR(320) NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + source TEXT NOT NULL, + destination TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); CREATE TABLE IF NOT EXISTS email_autoresponders ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - email VARCHAR(320) NOT NULL, - subject VARCHAR(255), - body TEXT, - is_active TINYINT(1) DEFAULT 1, - start_at DATETIME, - stop_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + email TEXT NOT NULL, + subject TEXT, + body TEXT, + is_active INTEGER DEFAULT 1, + start_at TEXT, + stop_at TEXT, + created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); -- ── Databases ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS databases ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - db_name VARCHAR(100) NOT NULL, - db_user VARCHAR(100) NOT NULL, - db_pass VARCHAR(255) NOT NULL, - db_type ENUM('mysql','postgresql') DEFAULT 'mysql', - size_mb FLOAT DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - INDEX idx_account (account_id), - INDEX idx_type (db_type) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- ── FTP Accounts ───────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS ftp_accounts ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - username VARCHAR(100) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - home_dir VARCHAR(500) NOT NULL, - quota_mb INT UNSIGNED DEFAULT 0, - status ENUM('active','suspended') DEFAULT 'active', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + db_name TEXT NOT NULL, + db_user TEXT NOT NULL, + db_pass TEXT NOT NULL, + db_type TEXT DEFAULT 'mysql' CHECK(db_type IN ('mysql','postgresql')), + size_mb REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); +CREATE INDEX IF NOT EXISTS idx_databases_account ON databases (account_id); + +-- ── FTP Accounts ────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ftp_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + home_dir TEXT NOT NULL, + quota_mb INTEGER DEFAULT 0, + status TEXT DEFAULT 'active' CHECK(status IN ('active','suspended')), + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); -- ── SSL Certificates ────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS ssl_certs ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - domain VARCHAR(253) NOT NULL, - type ENUM('lets_encrypt','self_signed','custom') DEFAULT 'lets_encrypt', + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + domain TEXT NOT NULL, + type TEXT DEFAULT 'lets_encrypt' CHECK(type IN ('lets_encrypt','self_signed','custom')), cert TEXT, private_key TEXT, chain TEXT, - issued_at DATETIME, - expires_at DATETIME, - auto_renew TINYINT(1) DEFAULT 1, - status ENUM('active','expired','pending','failed') DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - INDEX idx_domain (domain), - INDEX idx_expires (expires_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + issued_at TEXT, + expires_at TEXT, + auto_renew INTEGER DEFAULT 1, + status TEXT DEFAULT 'pending' CHECK(status IN ('active','expired','pending','failed')), + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_ssl_certs_domain ON ssl_certs (domain); +CREATE INDEX IF NOT EXISTS idx_ssl_certs_expires ON ssl_certs (expires_at); -- ── Cron Jobs ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS cron_jobs ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - command TEXT NOT NULL, - minute VARCHAR(20) DEFAULT '*', - hour VARCHAR(20) DEFAULT '*', - day VARCHAR(20) DEFAULT '*', - month VARCHAR(20) DEFAULT '*', - weekday VARCHAR(20) DEFAULT '*', - is_active TINYINT(1) DEFAULT 1, - last_run DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + command TEXT NOT NULL, + minute TEXT DEFAULT '*', + hour TEXT DEFAULT '*', + day TEXT DEFAULT '*', + month TEXT DEFAULT '*', + weekday TEXT DEFAULT '*', + is_active INTEGER DEFAULT 1, + last_run TEXT, + created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); -- ── PHP Configuration ───────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS php_configs ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL UNIQUE, - php_version VARCHAR(10) DEFAULT '8.3', - memory_limit VARCHAR(20) DEFAULT '256M', - max_execution_time INT DEFAULT 30, - upload_max_filesize VARCHAR(20) DEFAULT '64M', - post_max_size VARCHAR(20) DEFAULT '64M', - max_input_vars INT DEFAULT 1000, - display_errors TINYINT(1) DEFAULT 0, - error_reporting VARCHAR(50) DEFAULT 'E_ALL & ~E_NOTICE', - extensions JSON, - updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL UNIQUE, + php_version TEXT DEFAULT '8.3', + memory_limit TEXT DEFAULT '256M', + max_execution_time INTEGER DEFAULT 30, + upload_max_filesize TEXT DEFAULT '64M', + post_max_size TEXT DEFAULT '64M', + max_input_vars INTEGER DEFAULT 1000, + display_errors INTEGER DEFAULT 0, + error_reporting TEXT DEFAULT 'E_ALL & ~E_NOTICE', + extensions TEXT, + updated_at TEXT, FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); -- ── Backups ─────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS backups ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED NOT NULL, - filename VARCHAR(500) NOT NULL, - size_mb FLOAT DEFAULT 0, - type ENUM('full','partial','db_only','files_only') DEFAULT 'full', - status ENUM('pending','running','complete','failed') DEFAULT 'pending', - storage ENUM('local','s3','ftp','sftp') DEFAULT 'local', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - completed_at DATETIME, - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - INDEX idx_account (account_id), - INDEX idx_status (status) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + filename TEXT NOT NULL, + size_mb REAL DEFAULT 0, + type TEXT DEFAULT 'full' CHECK(type IN ('full','partial','db_only','files_only')), + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','running','complete','failed')), + storage TEXT DEFAULT 'local' CHECK(storage IN ('local','s3','ftp','sftp')), + created_at TEXT DEFAULT (datetime('now')), + completed_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_backups_account ON backups (account_id); +CREATE INDEX IF NOT EXISTS idx_backups_status ON backups (status); --- ── Server Stats / Monitoring ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS backup_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL UNIQUE, + frequency TEXT DEFAULT 'daily' CHECK(frequency IN ('hourly','daily','weekly','monthly')), + type TEXT DEFAULT 'full' CHECK(type IN ('full','files','database')), + retain_count INTEGER DEFAULT 7, + last_run TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); + +-- ── Server Stats ────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS server_stats ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - cpu_pct FLOAT, - ram_pct FLOAT, - disk_pct FLOAT, - load_1m FLOAT, - load_5m FLOAT, - load_15m FLOAT, - net_in_kb BIGINT UNSIGNED DEFAULT 0, - net_out_kb BIGINT UNSIGNED DEFAULT 0, - recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_recorded (recorded_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + cpu_pct REAL, + ram_pct REAL, + disk_pct REAL, + load_1m REAL, + load_5m REAL, + load_15m REAL, + net_in_kb INTEGER DEFAULT 0, + net_out_kb INTEGER DEFAULT 0, + recorded_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_server_stats_recorded ON server_stats (recorded_at); -- ── Notifications ───────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS notifications ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED, - title VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - type ENUM('info','success','warning','error') DEFAULT 'info', - is_read TINYINT(1) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - INDEX idx_user (user_id), - INDEX idx_unread (is_read) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + title TEXT NOT NULL, + message TEXT NOT NULL, + type TEXT DEFAULT 'info' CHECK(type IN ('info','success','warning','error')), + is_read INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications (is_read); -- ── API Tokens ──────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS api_tokens ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED NOT NULL, - name VARCHAR(100) NOT NULL, - token VARCHAR(128) NOT NULL UNIQUE, - permissions JSON, - last_used DATETIME, - expires_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - INDEX idx_token (token) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + permissions TEXT, + last_used TEXT, + expires_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_token ON api_tokens (token); -- ── Settings ────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS settings ( - `key` VARCHAR(100) PRIMARY KEY, - `value` TEXT, - updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `key` TEXT PRIMARY KEY, + `value` TEXT, + updated_at TEXT +); -INSERT INTO settings (`key`, `value`) VALUES - ('panel_name', 'NovaCPX'), - ('panel_version', '1.0.0'), - ('default_nameserver1', 'ns1.localhost'), - ('default_nameserver2', 'ns2.localhost'), - ('default_php', '8.3'), - ('mail_enabled', '1'), - ('ftp_enabled', '1'), - ('dns_enabled', '1'), - ('backup_dir', '/var/novacpx/backups'), - ('update_channel', 'stable'), - ('git_remote', 'https://github.com/myronblair/novacpx.git') -ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); +INSERT OR IGNORE INTO settings (`key`, `value`) VALUES + ('panel_name', 'NovaCPX'), + ('panel_version', '1.1.0'), + ('default_nameserver1', 'ns1.localhost'), + ('default_nameserver2', 'ns2.localhost'), + ('default_php', '8.3'), + ('mail_enabled', '1'), + ('ftp_enabled', '1'), + ('dns_enabled', '1'), + ('backup_dir', '/var/novacpx/backups'), + ('update_channel', 'stable'), + ('git_remote', 'https://github.com/myronblair/novacpx.git'), + ('proxy_mode', 'disabled'), + ('proxy_apache_port', '80'); +-- ── DKIM Keys ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS dkim_keys ( - `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - `account_id` INT UNSIGNED NOT NULL, - `domain` VARCHAR(253) NOT NULL, - `selector` VARCHAR(63) NOT NULL DEFAULT 'mail', - `public_key` TEXT NOT NULL, - `private_key_path` VARCHAR(500) NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_domain (domain), - CONSTRAINT fk_dkim_acct FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + domain TEXT NOT NULL UNIQUE, + selector TEXT NOT NULL DEFAULT 'mail', + public_key TEXT NOT NULL, + private_key_path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +-- ── Rate Limits ─────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS api_rate_limits ( - ip VARCHAR(45) NOT NULL, - endpoint VARCHAR(32) NOT NULL, - hits INT UNSIGNED NOT NULL DEFAULT 1, - window_start INT UNSIGNED NOT NULL DEFAULT 0, + ip TEXT NOT NULL, + endpoint TEXT NOT NULL, + hits INTEGER NOT NULL DEFAULT 1, + window_start INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (ip, endpoint) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +); +-- ── Proxy Hosts ─────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS proxy_hosts ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - account_id INT UNSIGNED, - domain VARCHAR(253) NOT NULL, - upstream VARCHAR(255) NOT NULL, - ssl_enabled TINYINT(1) NOT NULL DEFAULT 0, - enabled TINYINT(1) NOT NULL DEFAULT 1, + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER, + domain TEXT NOT NULL UNIQUE, + upstream TEXT NOT NULL, + ssl_enabled INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, custom_config TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_domain (domain), - CONSTRAINT fk_proxy_acct FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL +); -SET foreign_key_checks = 1; +-- ── Reseller Branding ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS reseller_branding ( + user_id INTEGER PRIMARY KEY, + panel_name TEXT NOT NULL DEFAULT 'NovaCPX', + logo_url TEXT, + favicon_url TEXT, + primary_color TEXT NOT NULL DEFAULT '#6366f1', + accent_color TEXT NOT NULL DEFAULT '#0ea5e9', + support_email TEXT, + support_url TEXT, + hide_powered_by INTEGER NOT NULL DEFAULT 0, + custom_css TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- ── Webmail SSO Tokens ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS webmail_sso_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + enc_pass TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_webmail_sso_expires ON webmail_sso_tokens (expires_at); + +-- ── WordPress Installs ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS wordpress_installs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + domain TEXT NOT NULL, + path TEXT DEFAULT '/', + db_name TEXT, + db_user TEXT, + db_pass TEXT, + wp_version TEXT, + admin_user TEXT, + admin_email TEXT, + status TEXT DEFAULT 'active' CHECK(status IN ('active','updating','suspended')), + staging_of INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_wp_installs_account ON wordpress_installs (account_id); + +-- ── Docker ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS docker_containers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + container_id TEXT, + name TEXT NOT NULL, + image TEXT NOT NULL, + app_key TEXT, + status TEXT DEFAULT 'pending' CHECK(status IN ('running','stopped','error','pending')), + ports TEXT, + memory_mb INTEGER, + cpus REAL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_docker_containers_account ON docker_containers (account_id); + +CREATE TABLE IF NOT EXISTS docker_compose_stacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER, + name TEXT NOT NULL, + stack_dir TEXT NOT NULL, + compose_file TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK(status IN ('running','stopped','error','pending')), + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS docker_quotas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + max_containers INTEGER DEFAULT 2, + max_memory_mb INTEGER DEFAULT 512, + max_cpus REAL DEFAULT 1.0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- ── Features ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL, + enabled INTEGER DEFAULT 0, + installed INTEGER DEFAULT 0, + install_cmd TEXT, + uninstall_cmd TEXT, + config_keys TEXT, + install_pid INTEGER, + install_log TEXT, + requires TEXT, + requires_restart INTEGER DEFAULT 0, + min_ram_mb INTEGER DEFAULT 0, + updated_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_features_category ON features (category); +CREATE INDEX IF NOT EXISTS idx_features_enabled ON features (enabled); + +PRAGMA foreign_keys = ON; diff --git a/panel/lib/Core.php b/panel/lib/Core.php index dc0331c..09b0c79 100644 --- a/panel/lib/Core.php +++ b/panel/lib/Core.php @@ -13,10 +13,9 @@ if (!$_cfg) { die(json_encode(['error' => 'NovaCPX not configured. Run the installer.'])); } -define('DB_HOST', $_cfg['database']['host'] ?? 'localhost'); -define('DB_NAME', $_cfg['database']['name'] ?? 'novacpx'); -define('DB_USER', $_cfg['database']['user'] ?? ''); -define('DB_PASS', $_cfg['database']['pass'] ?? ''); +define('DB_PATH', $_cfg['database']['path'] ?? '/var/lib/novacpx/panel.db'); +define('DB_WP_USER', $_cfg['database']['wp_user'] ?? ''); +define('DB_WP_PASS', $_cfg['database']['wp_pass'] ?? ''); define('SECRET_KEY', $_cfg['panel']['secret'] ?? ''); define('PANEL_VER', $_cfg['panel']['version'] ?? NOVACPX_VERSION); define('PORT_USER', (int)($_cfg['panel']['port_user'] ?? 8880)); diff --git a/panel/lib/DB.php b/panel/lib/DB.php index f698c50..1fe1bce 100644 --- a/panel/lib/DB.php +++ b/panel/lib/DB.php @@ -4,15 +4,21 @@ class DB { private PDO $pdo; private function __construct() { + $path = defined('DB_PATH') ? DB_PATH : '/var/lib/novacpx/panel.db'; + $dir = dirname($path); + if (!is_dir($dir)) mkdir($dir, 0750, true); + $this->pdo = new PDO( - "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", - DB_USER, DB_PASS, + "sqlite:{$path}", + null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, ] ); + $this->pdo->exec("PRAGMA journal_mode = WAL"); + $this->pdo->exec("PRAGMA foreign_keys = ON"); + $this->pdo->exec("PRAGMA busy_timeout = 5000"); } public static function getInstance(): self { @@ -20,8 +26,80 @@ class DB { return self::$instance; } + // Translate MySQL-isms to SQLite equivalents + private function translate(string $sql): string { + // ON DUPLICATE KEY UPDATE col=VALUES(col) → ON CONFLICT DO UPDATE SET col=excluded.col + $sql = preg_replace_callback( + '/ON DUPLICATE KEY UPDATE\s+(.+?)(?=\s*(?:;|$))/is', + function (array $m): string { + $pairs = preg_split('/,\s*/', trim($m[1])); + $sets = array_map(function (string $pair): string { + if (preg_match('/(\w+)\s*=\s*VALUES\s*\(\s*(\w+)\s*\)/i', $pair, $pm)) { + return "{$pm[1]}=excluded.{$pm[2]}"; + } + // col=? or col=expr — keep as-is + return $pair; + }, $pairs); + return 'ON CONFLICT DO UPDATE SET ' . implode(', ', $sets); + }, + $sql + ); + + // NOW() → datetime('now') + $sql = preg_replace('/\bNOW\(\)/i', "datetime('now')", $sql); + + // UNIX_TIMESTAMP() → strftime('%s','now') + $sql = preg_replace('/\bUNIX_TIMESTAMP\(\)/i', "strftime('%s','now')", $sql); + + // DATE_ADD(expr, INTERVAL n UNIT) → datetime(expr, '+n unit') + $sql = preg_replace_callback( + "/DATE_ADD\s*\(\s*(NOW\(\)|datetime\('now'\))\s*,\s*INTERVAL\s+(\d+)\s+(\w+)\s*\)/i", + function (array $m): string { + $n = $m[2]; + $unit = strtolower($m[3]); + // Map MySQL interval units to SQLite modifier strings + $map = [ + 'second' => 'second', 'seconds' => 'second', + 'minute' => 'minute', 'minutes' => 'minute', + 'hour' => 'hour', 'hours' => 'hour', + 'day' => 'day', 'days' => 'day', + 'month' => 'month', 'months' => 'month', + 'year' => 'year', 'years' => 'year', + ]; + $mod = $map[$unit] ?? $unit; + return "datetime('now', '+{$n} {$mod}')"; + }, + $sql + ); + + // DATE_SUB(expr, INTERVAL n UNIT) → datetime(expr, '-n unit') + $sql = preg_replace_callback( + "/DATE_SUB\s*\(\s*(NOW\(\)|datetime\('now'\))\s*,\s*INTERVAL\s+(\d+)\s+(\w+)\s*\)/i", + function (array $m): string { + $n = $m[2]; + $unit = strtolower($m[3]); + $map = [ + 'second' => 'second', 'seconds' => 'second', + 'minute' => 'minute', 'minutes' => 'minute', + 'hour' => 'hour', 'hours' => 'hour', + 'day' => 'day', 'days' => 'day', + 'month' => 'month', 'months' => 'month', + 'year' => 'year', 'years' => 'year', + ]; + $mod = $map[$unit] ?? $unit; + return "datetime('now', '-{$n} {$mod}')"; + }, + $sql + ); + + // IFNULL → COALESCE (SQLite supports both but be safe) + $sql = preg_replace('/\bIFNULL\s*\(/i', 'COALESCE(', $sql); + + return $sql; + } + public function execute(string $sql, array $params = []): PDOStatement { - $stmt = $this->pdo->prepare($sql); + $stmt = $this->pdo->prepare($this->translate($sql)); $stmt->execute($params); return $stmt; } diff --git a/panel/public/_branding.php b/panel/public/_branding.php index cab935d..2eed4a5 100644 --- a/panel/public/_branding.php +++ b/panel/public/_branding.php @@ -9,15 +9,15 @@ function novacpx_get_branding(): array { $cfg = @parse_ini_file('/etc/novacpx/config.ini', true); if (!$cfg) return $cache = []; try { + $dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db'; $pdo = new PDO( - "mysql:host={$cfg['database']['host']};dbname={$cfg['database']['name']};charset=utf8mb4", - $cfg['database']['user'], $cfg['database']['pass'], + "sqlite:{$dbPath}", null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); $token = $_COOKIE['ncpx_session'] ?? ''; if (!$token || strlen($token) < 32) return $cache = []; - $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE token = ? AND expires_at > NOW() LIMIT 1"); + $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now') LIMIT 1"); $stmt->execute([substr($token, 0, 128)]); $uid = (int)($stmt->fetchColumn() ?: 0); if (!$uid) return $cache = []; diff --git a/tools/migrate-to-sqlite.sh b/tools/migrate-to-sqlite.sh new file mode 100755 index 0000000..941e6d9 --- /dev/null +++ b/tools/migrate-to-sqlite.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Migrate NovaCPX panel DB from MySQL to SQLite +# Run as root on the NovaCPX VM. +# Usage: bash tools/migrate-to-sqlite.sh [--schema-only] + +set -euo pipefail + +SCHEMA_ONLY=false +[[ "${1:-}" == "--schema-only" ]] && SCHEMA_ONLY=true + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +DB_PATH="/var/lib/novacpx/panel.db" +SCHEMA="$REPO_ROOT/db/schema.sql" +CONFIG="/etc/novacpx/config.ini" +BACKUP_DIR="/var/lib/novacpx/migration-backup-$(date +%Y%m%d-%H%M%S)" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[✓]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; } + +[[ $EUID -ne 0 ]] && fail "Run as root." +command -v sqlite3 >/dev/null 2>&1 || { apt-get install -y sqlite3 >> /dev/null; log "sqlite3 installed"; } + +# ── Read MySQL creds from existing config ───────────────────────────────────── +read_ini() { grep -E "^\s*$1\s*=" "$CONFIG" | head -1 | cut -d= -f2- | tr -d ' '; } + +DB_HOST=$(read_ini host) +DB_NAME=$(read_ini name) +DB_USER=$(read_ini user) +DB_PASS=$(read_ini pass) +DB_WP_USER=$(read_ini wp_user) +DB_WP_PASS=$(read_ini wp_pass) +SECRET_KEY=$(read_ini secret) + +if [[ -z "$DB_NAME" ]]; then + warn "No MySQL config found in $CONFIG — this install may already be on SQLite." + warn "If you only need to (re)create the SQLite DB from schema, use --schema-only." + [[ "$SCHEMA_ONLY" == "false" ]] && exit 0 +fi + +# ── Backup ──────────────────────────────────────────────────────────────────── +log "Backing up to $BACKUP_DIR..." +mkdir -p "$BACKUP_DIR" +[[ -f "$DB_PATH" ]] && cp "$DB_PATH" "$BACKUP_DIR/panel.db.bak" +cp "$CONFIG" "$BACKUP_DIR/config.ini.bak" +[[ -n "$DB_NAME" ]] && mysqldump -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_DIR/mysql-dump.sql" 2>/dev/null && log "MySQL dump saved" || warn "MySQL dump failed (service may be down)" + +# ── Create SQLite DB from schema ────────────────────────────────────────────── +log "Creating SQLite database at $DB_PATH..." +mkdir -p /var/lib/novacpx +rm -f "$DB_PATH" +sqlite3 "$DB_PATH" < "$SCHEMA" +log "Schema applied" + +if [[ "$SCHEMA_ONLY" == "false" && -n "$DB_NAME" ]]; then + # ── Migrate data from MySQL → SQLite ───────────────────────────────────────── + log "Migrating data from MySQL ($DB_NAME)..." + + TABLES=( + users settings email_domains email_accounts dns_zones dns_records + accounts packages features account_features + ssl_certificates backups backup_schedules + ftp_accounts cron_jobs databases db_users + wordpress_installs + resellers reseller_branding reseller_packages + ip_addresses ssh_keys firewall_rules + docker_containers docker_compose_stacks docker_quotas + webmail_sso_tokens audit_log + ) + + for TABLE in "${TABLES[@]}"; do + # Check table exists in MySQL + ROWS=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM \`$TABLE\`;" 2>/dev/null) || { warn "Table $TABLE not found in MySQL — skipping"; continue; } + [[ "$ROWS" == "0" ]] && { log " $TABLE — empty, skip"; continue; } + + # Get column names from MySQL for this table + MYSQL_COLS=$(mysql -u "$DB_USER" -p"$DB_PASS" --skip-column-names --batch "$DB_NAME" \ + -e "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${DB_NAME}' AND TABLE_NAME='${TABLE}' ORDER BY ORDINAL_POSITION;" 2>/dev/null) + [[ -z "$MYSQL_COLS" ]] && { warn " $TABLE: could not get MySQL columns, skipping"; continue; } + + # Dump only the columns that exist in both MySQL and SQLite + mysql -u "$DB_USER" -p"$DB_PASS" --skip-column-names --batch "$DB_NAME" \ + -e "SELECT $(echo "$MYSQL_COLS" | tr '\n' ',' | sed 's/,$//') FROM \`$TABLE\`" 2>/dev/null | \ + python3 - "$TABLE" "$DB_PATH" "$MYSQL_COLS" <<'PYEOF' +import sys, sqlite3, csv, io + +table = sys.argv[1] +db_path = sys.argv[2] +mysql_cols = [c for c in sys.argv[3].splitlines() if c] +data = sys.stdin.read() +if not data.strip(): + sys.exit(0) + +con = sqlite3.connect(db_path) +cur = con.cursor() +cur.execute(f"SELECT name FROM pragma_table_info('{table}')") +sqlite_cols = {r[0] for r in cur.fetchall()} +if not sqlite_cols: + print(f" {table}: not in SQLite schema, skipping", file=sys.stderr) + con.close() + sys.exit(0) + +# Only insert columns present in both +common = [c for c in mysql_cols if c in sqlite_cols] +col_idx = [i for i, c in enumerate(mysql_cols) if c in sqlite_cols] +placeholders = ','.join(['?'] * len(common)) +col_list = ','.join(common) + +reader = csv.reader(io.StringIO(data), delimiter='\t') +rows = list(reader) +inserted = 0 +for row in rows: + if len(row) != len(mysql_cols): + continue + vals = [None if row[i] == 'NULL' else row[i] for i in col_idx] + try: + cur.execute(f"INSERT OR IGNORE INTO {table} ({col_list}) VALUES ({placeholders})", vals) + inserted += 1 + except Exception as e: + pass +con.commit() +con.close() +print(f" {table}: {inserted}/{len(rows)} rows migrated ({len(common)} cols)") +PYEOF + done + log "Data migration complete" +fi + +# ── Update config.ini ───────────────────────────────────────────────────────── +log "Updating $CONFIG to use SQLite..." + +# Build new [database] section +NEW_DB_SECTION="[database] +path = ${DB_PATH} +wp_user = ${DB_WP_USER} +wp_pass = ${DB_WP_PASS}" + +# Replace everything between [database] and the next [section] +python3 - "$CONFIG" "$NEW_DB_SECTION" <<'PYEOF' +import sys, re + +config_path = sys.argv[1] +new_section = sys.argv[2] + +with open(config_path) as f: + content = f.read() + +# Replace the [database] section with new content +content = re.sub( + r'\[database\].*?(?=\[|\Z)', + new_section + '\n\n', + content, + flags=re.DOTALL +) + +with open(config_path, 'w') as f: + f.write(content) + +print("config.ini updated") +PYEOF + +chown root:www-data "$CONFIG" +chmod 640 "$CONFIG" + +# ── Fix permissions ─────────────────────────────────────────────────────────── +chown www-data:www-data "$DB_PATH" +chmod 660 "$DB_PATH" + +log "Migration complete!" +log "Panel DB: $DB_PATH" +log "Backup: $BACKUP_DIR" +warn "Restart PHP-FPM to clear any cached DB connections:" +echo " systemctl restart php8.3-fpm php8.2-fpm php8.1-fpm php7.4-fpm 2>/dev/null || true"