587 lines
22 KiB
PHP
587 lines
22 KiB
PHP
<?php
|
|
// Shared helpers for opening, creating, and saving the questionnaire database.
|
|
require_once __DIR__ . '/common.php';
|
|
require_once __DIR__ . '/lib/sql_dialect.php';
|
|
|
|
if (defined('QDB_TEST_UPLOADS')) {
|
|
define('QDB_UPLOADS_DIR', QDB_TEST_UPLOADS);
|
|
define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database');
|
|
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
|
} else {
|
|
define('QDB_UPLOADS_DIR', __DIR__ . '/uploads');
|
|
define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database');
|
|
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
|
}
|
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
|
define('QDB_VERSION', 14);
|
|
|
|
/**
|
|
* Apply incremental schema fixes. Returns true if the 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;
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'client')) {
|
|
if (!qdb_column_exists($pdo, 'client', 'archived')) {
|
|
$pdo->exec('ALTER TABLE client ADD COLUMN archived INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'client', 'archivedAt')) {
|
|
$pdo->exec('ALTER TABLE client ADD COLUMN archivedAt INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'questionnaire')) {
|
|
if (!qdb_column_exists($pdo, 'questionnaire', 'structureRevision')) {
|
|
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'questionnaire', 'structureChangedAt')) {
|
|
$pdo->exec('ALTER TABLE questionnaire ADD COLUMN structureChangedAt INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'questionnaire_structure_snapshot')) {
|
|
$pdo->exec("
|
|
CREATE TABLE questionnaire_structure_snapshot (
|
|
questionnaireID TEXT NOT NULL,
|
|
structureRevision INTEGER NOT NULL,
|
|
createdAt INTEGER NOT NULL,
|
|
manifestJson TEXT NOT NULL DEFAULT '{}',
|
|
PRIMARY KEY (questionnaireID, structureRevision),
|
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'question')) {
|
|
if (!qdb_column_exists($pdo, 'question', 'retiredAt')) {
|
|
$pdo->exec('ALTER TABLE question ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'question', 'retiredInRevision')) {
|
|
$pdo->exec('ALTER TABLE question ADD COLUMN retiredInRevision INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'answer_option')) {
|
|
if (!qdb_column_exists($pdo, 'answer_option', 'retiredAt')) {
|
|
$pdo->exec('ALTER TABLE answer_option ADD COLUMN retiredAt INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
|
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureRevision')) {
|
|
$pdo->exec('ALTER TABLE questionnaire_submission ADD COLUMN structureRevision INTEGER NOT NULL DEFAULT 1');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'questionnaire_submission', 'structureSnapshotJson')) {
|
|
$pdo->exec("ALTER TABLE questionnaire_submission ADD COLUMN structureSnapshotJson TEXT NOT NULL DEFAULT '{}'");
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
|
|
&& qdb_table_exists($pdo, 'questionnaire')) {
|
|
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
|
if (qdb_backfill_structure_snapshots($pdo)) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
|
$pdo->exec("
|
|
CREATE TABLE questionnaire_submission (
|
|
submissionID TEXT NOT NULL PRIMARY KEY,
|
|
clientCode TEXT NOT NULL,
|
|
questionnaireID TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
submittedAt INTEGER NOT NULL,
|
|
submittedByUserID TEXT NOT NULL DEFAULT '',
|
|
submittedByRole TEXT NOT NULL DEFAULT '',
|
|
assignedByCoach TEXT,
|
|
status TEXT NOT NULL DEFAULT '',
|
|
startedAt INTEGER,
|
|
completedAt INTEGER,
|
|
sumPoints INTEGER,
|
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
|
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
|
UNIQUE(clientCode, questionnaireID, version)
|
|
)
|
|
");
|
|
qdb_create_index_if_not_exists($pdo, 'idx_submission_client_qn', 'questionnaire_submission', 'clientCode, questionnaireID');
|
|
qdb_create_index_if_not_exists($pdo, 'idx_submission_client_time', 'questionnaire_submission', 'clientCode, submittedAt');
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
|
$pdo->exec("
|
|
CREATE TABLE client_answer_submission (
|
|
submissionID TEXT NOT NULL,
|
|
questionID TEXT NOT NULL,
|
|
answerOptionID TEXT,
|
|
freeTextValue TEXT,
|
|
numericValue REAL,
|
|
answeredAt INTEGER,
|
|
PRIMARY KEY (submissionID, questionID),
|
|
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
|
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
|
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'client_followup_note')) {
|
|
$pdo->exec("
|
|
CREATE TABLE client_followup_note (
|
|
clientCode TEXT NOT NULL PRIMARY KEY,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
updatedByUserID TEXT NOT NULL DEFAULT '',
|
|
updatedAt INTEGER NOT NULL,
|
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'session')) {
|
|
$pdo->exec("
|
|
CREATE TABLE session (
|
|
token TEXT NOT NULL PRIMARY KEY,
|
|
userID TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
entityID TEXT NOT NULL DEFAULT '',
|
|
createdAt INTEGER NOT NULL,
|
|
expiresAt INTEGER NOT NULL,
|
|
temp INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'system_setting')) {
|
|
$pdo->exec("
|
|
CREATE TABLE system_setting (
|
|
settingKey TEXT NOT NULL PRIMARY KEY,
|
|
settingValue TEXT NOT NULL DEFAULT ''
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'question_score_rule')) {
|
|
$pdo->exec("
|
|
CREATE TABLE question_score_rule (
|
|
ruleID TEXT NOT NULL PRIMARY KEY,
|
|
questionID TEXT NOT NULL,
|
|
scopeKey TEXT NOT NULL DEFAULT '',
|
|
levelKey TEXT NOT NULL,
|
|
points INTEGER NOT NULL DEFAULT 0,
|
|
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
|
UNIQUE(questionID, scopeKey, levelKey)
|
|
)
|
|
");
|
|
qdb_create_index_if_not_exists($pdo, 'idx_score_rule_question', 'question_score_rule', 'questionID');
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'scoring_profile')) {
|
|
$pdo->exec("
|
|
CREATE TABLE scoring_profile (
|
|
profileID TEXT NOT NULL PRIMARY KEY,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
isActive INTEGER NOT NULL DEFAULT 1,
|
|
greenMin INTEGER NOT NULL DEFAULT 0,
|
|
greenMax INTEGER NOT NULL DEFAULT 12,
|
|
yellowMin INTEGER NOT NULL DEFAULT 13,
|
|
yellowMax INTEGER NOT NULL DEFAULT 36,
|
|
redMin INTEGER NOT NULL DEFAULT 37,
|
|
processCompleteBands TEXT NOT NULL DEFAULT '[]',
|
|
createdAt INTEGER NOT NULL DEFAULT 0,
|
|
updatedAt INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'scoring_profile_questionnaire')) {
|
|
$pdo->exec("
|
|
CREATE TABLE scoring_profile_questionnaire (
|
|
profileID TEXT NOT NULL,
|
|
questionnaireID TEXT NOT NULL,
|
|
weight REAL NOT NULL DEFAULT 1.0,
|
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (profileID, questionnaireID),
|
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
|
)
|
|
");
|
|
$changed = true;
|
|
}
|
|
|
|
if (!qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
|
$pdo->exec("
|
|
CREATE TABLE client_scoring_profile_result (
|
|
clientCode TEXT NOT NULL,
|
|
profileID TEXT NOT NULL,
|
|
weightedTotal REAL NOT NULL DEFAULT 0,
|
|
band TEXT NOT NULL DEFAULT '',
|
|
computedAt INTEGER NOT NULL DEFAULT 0,
|
|
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
|
|
coachBand TEXT NOT NULL DEFAULT '',
|
|
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
|
|
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (clientCode, profileID),
|
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
|
|
)
|
|
");
|
|
qdb_create_index_if_not_exists($pdo, 'idx_client_profile_result_profile', 'client_scoring_profile_result', 'profileID');
|
|
$changed = true;
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachBand')) {
|
|
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachBand TEXT NOT NULL DEFAULT ''");
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedAt')) {
|
|
$pdo->exec('ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedAt INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedByUserID')) {
|
|
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedByUserID TEXT NOT NULL DEFAULT ''");
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
|
require_once __DIR__ . '/lib/submissions.php';
|
|
if (qdb_backfill_submissions_from_live($pdo)) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 10
|
|
&& qdb_table_exists($pdo, 'completed_questionnaire')
|
|
&& qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
|
require_once __DIR__ . '/lib/scoring.php';
|
|
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) {
|
|
require_once __DIR__ . '/lib/scoring.php';
|
|
if (qdb_backfill_glass_score_rules($pdo) > 0) {
|
|
$changed = true;
|
|
}
|
|
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
|
|
$changed = true;
|
|
}
|
|
if (qdb_seed_default_rhs_scoring_profile($pdo) !== null) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) {
|
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) {
|
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'yellowMin')) {
|
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN yellowMin INTEGER NOT NULL DEFAULT 13');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'redMin')) {
|
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN redMin INTEGER NOT NULL DEFAULT 37');
|
|
$changed = true;
|
|
}
|
|
$pdo->exec(
|
|
'UPDATE scoring_profile SET
|
|
greenMin = COALESCE(greenMin, 0),
|
|
yellowMin = greenMax + 1,
|
|
redMin = yellowMax + 1'
|
|
);
|
|
$changed = true;
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) {
|
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) {
|
|
$pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'");
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 14) {
|
|
if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) {
|
|
$pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1');
|
|
$changed = true;
|
|
}
|
|
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
|
|
$pdo->exec(
|
|
'CREATE TABLE questionnaire_language_disable (
|
|
questionnaireID TEXT NOT NULL,
|
|
languageCode TEXT NOT NULL,
|
|
PRIMARY KEY (questionnaireID, languageCode),
|
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
|
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
|
)'
|
|
);
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
|
$pdo->exec(
|
|
'UPDATE questionnaire_submission SET submittedAt = completedAt
|
|
WHERE completedAt IS NOT NULL AND submittedAt != completedAt'
|
|
);
|
|
$changed = true;
|
|
}
|
|
|
|
$currentVersion = qdb_schema_version($pdo);
|
|
if ($currentVersion < QDB_VERSION) {
|
|
qdb_set_foreign_keys($pdo, false);
|
|
try {
|
|
qdb_exec_schema_sql($pdo, $schemaSql);
|
|
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
|
|
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
qdb_set_schema_version($pdo, QDB_VERSION);
|
|
} finally {
|
|
qdb_set_foreign_keys($pdo, true);
|
|
}
|
|
$changed = true;
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
function qdb_mysql_ensure_ready(PDO $pdo): void {
|
|
static $ready = false;
|
|
if ($ready) {
|
|
return;
|
|
}
|
|
|
|
$lockName = 'nat_as_schema_migration';
|
|
$lockStmt = $pdo->prepare('SELECT GET_LOCK(:name, 30)');
|
|
$lockStmt->execute([':name' => $lockName]);
|
|
if ((int)$lockStmt->fetchColumn() !== 1) {
|
|
throw new RuntimeException('Could not acquire MySQL schema migration lock');
|
|
}
|
|
|
|
try {
|
|
$schemaPath = qdb_schema_file();
|
|
$schemaSql = file_get_contents($schemaPath);
|
|
if ($schemaSql === false) {
|
|
throw new Exception('Could not read MySQL schema');
|
|
}
|
|
if (!qdb_table_exists($pdo, 'users') || qdb_schema_version($pdo) < QDB_VERSION) {
|
|
qdb_exec_schema_file($pdo, $schemaPath);
|
|
}
|
|
qdb_apply_migrations($pdo, $schemaSql);
|
|
$ready = true;
|
|
} finally {
|
|
$releaseStmt = $pdo->prepare('SELECT RELEASE_LOCK(:name)');
|
|
$releaseStmt->execute([':name' => $lockName]);
|
|
}
|
|
}
|
|
|
|
function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void {
|
|
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
|
if (flock($lockFp, LOCK_EX | LOCK_NB)) {
|
|
return;
|
|
}
|
|
if ($attempt < $maxAttempts - 1) {
|
|
usleep($sleepMicros);
|
|
}
|
|
}
|
|
throw new Exception('Could not acquire lock');
|
|
}
|
|
|
|
function qdb_open_writable_lock() {
|
|
$lockFp = fopen(QDB_LOCK, 'c');
|
|
if ($lockFp === false) {
|
|
throw new Exception('Could not open lock file');
|
|
}
|
|
qdb_flock_exclusive_with_retry($lockFp);
|
|
return $lockFp;
|
|
}
|
|
|
|
function qdb_acquire_lock() {
|
|
return qdb_open_writable_lock();
|
|
}
|
|
|
|
/**
|
|
* Returns [PDO $pdo, string $tmpPath, resource|null $lockFp].
|
|
*/
|
|
function qdb_open(bool $writable = false): array {
|
|
if (qdb_is_mysql()) {
|
|
[$pdo] = qdb_mysql_open();
|
|
qdb_mysql_ensure_ready($pdo);
|
|
return [$pdo, '', null];
|
|
}
|
|
|
|
if (!$writable) {
|
|
require_once __DIR__ . '/lib/read_db_cache.php';
|
|
return qdb_read_db_open();
|
|
}
|
|
|
|
$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 = qdb_open_writable_lock();
|
|
|
|
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
|
|
$masterKey = get_master_key_bytes();
|
|
|
|
$sql = file_get_contents(QDB_SCHEMA);
|
|
if ($sql === false) {
|
|
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) {
|
|
flock($lockFp, LOCK_UN);
|
|
fclose($lockFp);
|
|
throw new Exception('Could not read stored DB');
|
|
}
|
|
try {
|
|
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
|
} catch (Throwable $e) {
|
|
error_log(sprintf(
|
|
'qdb_open decrypt failed: path=%s realpath=%s enc_bytes=%d key_sha=%s sapi=%s env_readable=%s common=%s',
|
|
$dbPath,
|
|
realpath($dbPath) ?: 'none',
|
|
strlen($storedEnc),
|
|
hash('sha256', $masterKey),
|
|
PHP_SAPI,
|
|
is_readable(__DIR__ . '/.env') ? 'yes' : 'no',
|
|
realpath(__DIR__ . '/common.php') ?: __DIR__ . '/common.php'
|
|
));
|
|
throw $e;
|
|
}
|
|
if (file_put_contents($tmpDb, $decrypted) === false) {
|
|
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);
|
|
qdb_set_schema_version($pdo, QDB_VERSION);
|
|
$pdo = null;
|
|
}
|
|
|
|
$pdo = new PDO("sqlite:$tmpDb");
|
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
qdb_set_foreign_keys($pdo, true);
|
|
|
|
qdb_apply_migrations($pdo, $sql);
|
|
|
|
return [$pdo, $tmpDb, $lockFp];
|
|
}
|
|
|
|
function qdb_save(string $tmpDb, $lockFp): void {
|
|
if (qdb_is_mysql()) {
|
|
return;
|
|
}
|
|
|
|
$masterKey = get_master_key_bytes();
|
|
try {
|
|
$ck = new PDO('sqlite:' . $tmpDb);
|
|
$ck->exec('PRAGMA wal_checkpoint(FULL);');
|
|
} catch (Throwable $e) {
|
|
// best-effort before reading plaintext 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);
|
|
}
|
|
|
|
require_once __DIR__ . '/lib/read_db_cache.php';
|
|
qdb_read_cache_invalidate();
|
|
}
|
|
|
|
function qdb_discard(string $tmpDb, $lockFp): void {
|
|
if (qdb_is_mysql()) {
|
|
return;
|
|
}
|
|
if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
|
|
return;
|
|
}
|
|
@unlink($tmpDb);
|
|
if ($lockFp) {
|
|
@flock($lockFp, LOCK_UN);
|
|
@fclose($lockFp);
|
|
}
|
|
}
|
|
|
|
/** @return array{0: PDO, 1: string, 2: resource|null} */
|
|
function qdb_open_read_or_fail(): array {
|
|
try {
|
|
return qdb_open(false);
|
|
} catch (Throwable $e) {
|
|
require_once __DIR__ . '/lib/errors.php';
|
|
qdb_emit_api_error($e, 'Database read');
|
|
}
|
|
}
|
|
|
|
/** @return array{0: PDO, 1: string, 2: resource|null} */
|
|
function qdb_open_write_or_fail(): array {
|
|
try {
|
|
return qdb_open(true);
|
|
} catch (Throwable $e) {
|
|
require_once __DIR__ . '/lib/errors.php';
|
|
qdb_emit_api_error($e, 'Database write');
|
|
}
|
|
}
|