query('PRAGMA table_info(' . $table . ')'); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { if (($col['name'] ?? '') === $column) { return true; } } return false; } /** * Apply incremental schema fixes. Returns true if the temp DB was modified. */ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $changed = false; if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) { $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); $changed = true; } $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); if ($currentVersion < QDB_VERSION) { $pdo->exec('PRAGMA foreign_keys = OFF;'); $pdo->exec($schemaSql); if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) { $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); } $pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';'); $pdo->exec('PRAGMA foreign_keys = ON;'); $changed = true; } return $changed; } function qdb_acquire_lock() { $lockFp = fopen(QDB_LOCK, 'c'); if ($lockFp === false) { throw new Exception('Could not open lock file'); } if (!flock($lockFp, LOCK_EX)) { fclose($lockFp); throw new Exception('Could not acquire lock'); } return $lockFp; } /** * Decrypt the master DB file into a temp SQLite file, or create a fresh one * from schema.sql if no DB exists yet. * * Returns [PDO $pdo, string $tmpPath, resource|null $lockFp]. * If $writable is true, an exclusive lock is held -- caller MUST call * qdb_save() or qdb_discard() afterwards. */ function qdb_open(bool $writable = false): array { $dbPath = QDB_PATH; if (!is_dir(dirname($dbPath))) { if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) { throw new Exception("Could not create uploads directory"); } } $lockFp = null; if ($writable) { $lockFp = fopen(QDB_LOCK, 'c'); if ($lockFp === false) throw new Exception("Could not open lock file"); if (!flock($lockFp, LOCK_EX)) { fclose($lockFp); throw new Exception("Could not acquire lock"); } } $tmpDb = tempnam(sys_get_temp_dir(), 'qdb_'); $masterKey = get_master_key_bytes(); $sql = file_get_contents(QDB_SCHEMA); if ($sql === false) { if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } throw new Exception("Could not read schema.sql"); } if (file_exists($dbPath) && is_file($dbPath)) { $storedEnc = @file_get_contents($dbPath); if ($storedEnc === false) { if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } throw new Exception("Could not read stored DB"); } $decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey); if (file_put_contents($tmpDb, $decrypted) === false) { if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } throw new Exception("Could not write temp DB"); } } else { $pdo = new PDO("sqlite:$tmpDb"); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec($sql); $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo = null; } $pdo = new PDO("sqlite:$tmpDb"); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec("PRAGMA foreign_keys = ON;"); $schemaChanged = qdb_apply_migrations($pdo, $sql); // Read-only opens discard the temp file; persist one-off migrations to the master DB. if ($schemaChanged && !$writable) { $migrateLock = qdb_acquire_lock(); try { qdb_save($tmpDb, $migrateLock); } catch (Throwable $e) { @unlink($tmpDb); flock($migrateLock, LOCK_UN); fclose($migrateLock); throw $e; } return qdb_open(false); } return [$pdo, $tmpDb, $lockFp]; } /** * Encrypt the temp DB and atomically write it back to the master path. * Releases the lock. */ function qdb_save(string $tmpDb, $lockFp): void { $masterKey = get_master_key_bytes(); $plainDb = file_get_contents($tmpDb); if ($plainDb === false) throw new Exception("Could not read temp DB for save"); $enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey); $dbPath = QDB_PATH; $tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_'); if (file_put_contents($tmpEncrypted, $enc) === false) { throw new Exception("Could not write encrypted DB"); } if (!@rename($tmpEncrypted, $dbPath)) { if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) { throw new Exception("Could not save encrypted DB (rename/copy failed)"); } } @chmod($dbPath, 0644); @unlink($tmpDb); if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } } /** * Discard changes -- clean up temp file and release lock without saving. */ function qdb_discard(string $tmpDb, $lockFp): void { @unlink($tmpDb); if ($lockFp) { @flock($lockFp, LOCK_UN); @fclose($lockFp); } }