Files
nat-as-server/db_init.php

339 lines
11 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, '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, '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;
}
/**
* Exclusive lock with short retries (non-blocking attempts). Avoids hung PHP workers
* when two writes overlap (app upload + website edit).
*/
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();
}
/**
* 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 = 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) {
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");
}
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) {
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);
}
}
/**
* Open a read-only DB connection; on failure logs and returns a JSON error response.
*
* @return array{0: PDO, 1: string, 2: resource|null}|null
*/
/**
* @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');
}
}