272 lines
9.5 KiB
PHP
272 lines
9.5 KiB
PHP
<?php
|
|
// /var/www/html/db_init.php
|
|
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
|
|
require_once __DIR__ . '/common.php';
|
|
|
|
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
|
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
|
define('QDB_VERSION', 6);
|
|
|
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
|
$stmt = $pdo->prepare(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t LIMIT 1"
|
|
);
|
|
$stmt->execute([':t' => $table]);
|
|
return (bool)$stmt->fetchColumn();
|
|
}
|
|
|
|
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
|
|
$stmt = $pdo->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;
|
|
}
|
|
|
|
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)
|
|
)
|
|
");
|
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_qn ON questionnaire_submission(clientCode, questionnaireID)');
|
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_time ON 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, 'questionnaire_submission')) {
|
|
require_once __DIR__ . '/lib/submissions.php';
|
|
if (qdb_backfill_submissions_from_live($pdo)) {
|
|
$changed = true;
|
|
}
|
|
}
|
|
|
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
|
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 = (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);
|
|
}
|
|
}
|