Implement bulk export of client answers for coaches and enhance database handling in db_init.php
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -255,3 +255,30 @@ function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): arr
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
|
||||
*
|
||||
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array {
|
||||
if (($tokenRec['role'] ?? '') !== 'coach') {
|
||||
return [];
|
||||
}
|
||||
$coachID = trim((string)($tokenRec['entityID'] ?? ''));
|
||||
if ($coachID === '') {
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) {
|
||||
$out[] = [
|
||||
'clientCode' => $clientCode,
|
||||
'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
225
lib/read_db_cache.php
Normal file
225
lib/read_db_cache.php
Normal file
@ -0,0 +1,225 @@
|
||||
<?php
|
||||
/**
|
||||
* Per-worker read-only DB snapshot in RAM (memfd on Linux) — no plaintext on durable disk.
|
||||
*/
|
||||
|
||||
/** Passed as $tmpDb when the PDO comes from the worker cache (qdb_discard is a no-op). */
|
||||
define('QDB_READONLY_CACHE_HANDLE', "\0qdb_cached_read");
|
||||
|
||||
define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: null}
|
||||
*/
|
||||
function qdb_read_db_open(): array {
|
||||
$signature = qdb_read_cache_signature();
|
||||
$cached = qdb_read_cache_get($signature);
|
||||
if ($cached !== null) {
|
||||
return [$cached, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
|
||||
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
|
||||
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
function qdb_read_cache_invalidate(): void {
|
||||
qdb_read_cache_reset();
|
||||
qdb_read_generation_bump();
|
||||
}
|
||||
|
||||
function qdb_read_cache_signature(): string {
|
||||
$gen = qdb_read_generation_current();
|
||||
if (!is_file(QDB_PATH)) {
|
||||
return $gen . ':missing';
|
||||
}
|
||||
$mtime = @filemtime(QDB_PATH);
|
||||
$size = @filesize(QDB_PATH);
|
||||
return $gen . ':' . (int)$mtime . ':' . (int)$size;
|
||||
}
|
||||
|
||||
function qdb_read_generation_current(): string {
|
||||
if (!is_file(QDB_READ_GENERATION_FILE)) {
|
||||
return '0';
|
||||
}
|
||||
$raw = @file_get_contents(QDB_READ_GENERATION_FILE);
|
||||
return $raw === false || $raw === '' ? '0' : trim($raw);
|
||||
}
|
||||
|
||||
function qdb_read_generation_bump(): void {
|
||||
$dir = dirname(QDB_READ_GENERATION_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
$next = (string)((int)qdb_read_generation_current() + 1);
|
||||
@file_put_contents(QDB_READ_GENERATION_FILE, $next, LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: int} memfdPath empty when using disk fallback
|
||||
*/
|
||||
function qdb_read_materialize_decrypted_db(): array {
|
||||
$dbPath = QDB_PATH;
|
||||
$masterKey = get_master_key_bytes();
|
||||
$sql = file_get_contents(QDB_SCHEMA);
|
||||
if ($sql === false) {
|
||||
throw new Exception('Could not read schema.sql');
|
||||
}
|
||||
|
||||
if (!file_exists($dbPath) || !is_file($dbPath)) {
|
||||
$mem = qdb_read_open_sqlite_on_path(':memory:', $sql, true);
|
||||
return [$mem, '', -1];
|
||||
}
|
||||
|
||||
$storedEnc = @file_get_contents($dbPath);
|
||||
if ($storedEnc === false) {
|
||||
throw new Exception('Could not read stored DB');
|
||||
}
|
||||
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
||||
|
||||
$memfd = qdb_memfd_write($decrypted);
|
||||
if ($memfd !== null) {
|
||||
[$fd, $path] = $memfd;
|
||||
$pdo = qdb_read_open_sqlite_on_path($path, $sql, false);
|
||||
return [$pdo, $path, $fd];
|
||||
}
|
||||
|
||||
$fallbackDir = QDB_UPLOADS_DIR . '/.private_read';
|
||||
if (!is_dir($fallbackDir)) {
|
||||
@mkdir($fallbackDir, 0700, true);
|
||||
}
|
||||
$fallback = $fallbackDir . '/snap_' . getmypid() . '_' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
if (file_put_contents($fallback, $decrypted) === false) {
|
||||
throw new Exception('Could not write private read snapshot');
|
||||
}
|
||||
@chmod($fallback, 0600);
|
||||
$pdo = qdb_read_open_sqlite_on_path($fallback, $sql, false);
|
||||
return [$pdo, $fallback, -1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}|null [fd, /proc/self/fd/N]
|
||||
*/
|
||||
function qdb_memfd_write(string $bytes): ?array {
|
||||
if (PHP_OS_FAMILY !== 'Linux') {
|
||||
return null;
|
||||
}
|
||||
if (!extension_loaded('ffi')) {
|
||||
return null;
|
||||
}
|
||||
static $ffi = null;
|
||||
try {
|
||||
if ($ffi === null) {
|
||||
$ffi = FFI::cdef(
|
||||
'int memfd_create(const char *name, unsigned int flags);',
|
||||
'libc.so.6'
|
||||
);
|
||||
}
|
||||
$MFD_CLOEXEC = 1;
|
||||
$fd = (int)$ffi->memfd_create('qdb_read', $MFD_CLOEXEC);
|
||||
if ($fd < 0) {
|
||||
return null;
|
||||
}
|
||||
$written = @file_put_contents('/proc/self/fd/' . $fd, $bytes);
|
||||
if ($written === false || $written !== strlen($bytes)) {
|
||||
@close($fd);
|
||||
return null;
|
||||
}
|
||||
return [$fd, '/proc/self/fd/' . $fd];
|
||||
} catch (Throwable $e) {
|
||||
error_log('qdb_memfd_write: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_read_open_sqlite_on_path(string $sqlitePath, string $schemaSql, bool $fresh): PDO {
|
||||
if ($fresh) {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec($schemaSql);
|
||||
$pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';');
|
||||
} else {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
// DELETE (not WAL) so a migration persist via file_get_contents + qdb_save is complete.
|
||||
$pdo->exec('PRAGMA journal_mode = DELETE;');
|
||||
|
||||
$schemaChanged = qdb_apply_migrations($pdo, $schemaSql);
|
||||
if ($schemaChanged) {
|
||||
$migrateLock = qdb_acquire_lock();
|
||||
try {
|
||||
if ($sqlitePath === ':memory:') {
|
||||
throw new Exception('Cannot persist migration from in-memory read snapshot');
|
||||
}
|
||||
$savePath = $sqlitePath;
|
||||
if (str_starts_with($sqlitePath, '/proc/')) {
|
||||
$savePath = tempnam(sys_get_temp_dir(), 'qdb_migrate_');
|
||||
$bytes = file_get_contents($sqlitePath);
|
||||
if ($bytes === false || file_put_contents($savePath, $bytes) === false) {
|
||||
throw new Exception('Could not copy memfd DB for migration save');
|
||||
}
|
||||
}
|
||||
$pdo->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
qdb_save($savePath, $migrateLock);
|
||||
if ($savePath !== $sqlitePath && is_file($savePath)) {
|
||||
@unlink($savePath);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
flock($migrateLock, LOCK_UN);
|
||||
fclose($migrateLock);
|
||||
throw $e;
|
||||
}
|
||||
return qdb_read_db_open()[0];
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PDO|null
|
||||
*/
|
||||
function qdb_read_cache_get(string $signature) {
|
||||
$state = &qdb_read_cache_state();
|
||||
if ($state['signature'] === $signature && $state['pdo'] instanceof PDO) {
|
||||
return $state['pdo'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function qdb_read_cache_store(string $signature, PDO $pdo, string $memfdPath, int $memfdFd): void {
|
||||
qdb_read_cache_reset();
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = $signature;
|
||||
$state['pdo'] = $pdo;
|
||||
$state['memfdPath'] = $memfdPath;
|
||||
$state['memfdFd'] = $memfdFd;
|
||||
}
|
||||
|
||||
function qdb_read_cache_reset(): void {
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = '';
|
||||
$state['pdo'] = null;
|
||||
if ($state['memfdFd'] >= 0) {
|
||||
@close($state['memfdFd']);
|
||||
}
|
||||
$state['memfdFd'] = -1;
|
||||
if ($state['memfdPath'] !== '' && is_file($state['memfdPath'])) {
|
||||
@unlink($state['memfdPath']);
|
||||
}
|
||||
$state['memfdPath'] = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{signature: string, pdo: ?PDO, memfdPath: string, memfdFd: int}
|
||||
*/
|
||||
function &qdb_read_cache_state(): array {
|
||||
static $state = [
|
||||
'signature' => '',
|
||||
'pdo' => null,
|
||||
'memfdPath' => '',
|
||||
'memfdFd' => -1,
|
||||
];
|
||||
return $state;
|
||||
}
|
||||
Reference in New Issue
Block a user