586 lines
21 KiB
PHP
586 lines
21 KiB
PHP
<?php
|
|
// /var/www/html/common.php
|
|
// Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt).
|
|
|
|
/**
|
|
* Load KEY=value pairs from .env in the project root.
|
|
* Does not override variables already set in the real environment.
|
|
*/
|
|
function qdb_load_dotenv(string $path): void {
|
|
if (!is_readable($path)) {
|
|
return;
|
|
}
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
|
if ($lines === false) {
|
|
return;
|
|
}
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if ($line === '' || $line[0] === '#') {
|
|
continue;
|
|
}
|
|
$eq = strpos($line, '=');
|
|
if ($eq === false) {
|
|
continue;
|
|
}
|
|
$name = trim(substr($line, 0, $eq));
|
|
$value = trim(substr($line, $eq + 1));
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$len = strlen($value);
|
|
if ($len >= 2) {
|
|
$q = $value[0];
|
|
if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
}
|
|
$existing = getenv($name);
|
|
if ($existing !== false && $existing !== '') {
|
|
continue;
|
|
}
|
|
putenv("$name=$value");
|
|
$_ENV[$name] = $value;
|
|
}
|
|
}
|
|
|
|
qdb_load_dotenv(__DIR__ . '/.env');
|
|
|
|
// --- MASTER-KEY (Server-seitig zum Speichern der DB) ---
|
|
// Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert.
|
|
function get_master_key_bytes(): string {
|
|
$env = getenv('QDB_MASTER_KEY');
|
|
if (!$env || $env === '') {
|
|
throw new RuntimeException('QDB_MASTER_KEY environment variable is not set. Cannot proceed without a master key.');
|
|
}
|
|
$b = base64_decode($env, true);
|
|
if ($b !== false && strlen($b) > 0) {
|
|
return str_pad(substr($b, 0, 32), 32, "\0");
|
|
}
|
|
return str_pad(substr($env, 0, 32), 32, "\0");
|
|
}
|
|
|
|
// --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) ---
|
|
// Kompatibel zu Android-Implementierung: salt = leer, info = "qdb-aes"
|
|
function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string {
|
|
$ikm = @hex2bin(trim($tokenHex));
|
|
if ($ikm === false) $ikm = $tokenHex; // Falls bereits binär
|
|
if (function_exists('hash_hkdf')) {
|
|
return hash_hkdf('sha256', $ikm, $len, $info, '');
|
|
}
|
|
// Fallback: eigene HKDF-Implementierung
|
|
$hashLen = 32;
|
|
$salt = str_repeat("\0", $hashLen);
|
|
$prk = hash_hmac('sha256', $ikm, $salt, true);
|
|
$okm = '';
|
|
$t = '';
|
|
$n = (int)ceil($len / $hashLen);
|
|
for ($i = 1; $i <= $n; $i++) {
|
|
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
|
|
$okm .= $t;
|
|
}
|
|
return substr($okm, 0, $len);
|
|
}
|
|
|
|
// --- Token-Storage (DB-backed via session table) ---
|
|
|
|
function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$now = time();
|
|
$pdo->prepare(
|
|
"INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
|
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
|
|
)->execute([
|
|
':t' => $token,
|
|
':uid' => $extra['userID'] ?? '',
|
|
':role' => $extra['role'] ?? '',
|
|
':eid' => $extra['entityID'] ?? '',
|
|
':ca' => $now,
|
|
':ea' => $now + $ttlSeconds,
|
|
':tmp' => (int)(!empty($extra['temp'])),
|
|
]);
|
|
// Opportunistic cleanup: remove expired sessions
|
|
$pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
function token_get_record(string $token): ?array {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
|
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
|
|
$stmt->execute([':t' => $token, ':now' => time()]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
qdb_discard($tmpDb, $lockFp);
|
|
if (!$row) return null;
|
|
// Map DB columns to the keys the rest of the codebase expects
|
|
return [
|
|
'token' => $row['token'],
|
|
'role' => $row['role'],
|
|
'entityID' => $row['entityID'],
|
|
'userID' => $row['userID'],
|
|
'created' => (int)$row['createdAt'],
|
|
'exp' => (int)$row['expiresAt'],
|
|
'temp' => (int)$row['temp'],
|
|
];
|
|
}
|
|
|
|
function token_revoke(string $token): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => $token]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
// --- Auth helpers for endpoints ---
|
|
function get_bearer_token(): ?string {
|
|
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
|
if (stripos($auth, 'Bearer ') === 0) {
|
|
return substr($auth, 7);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function require_valid_token(): array {
|
|
$token = get_bearer_token();
|
|
if (!$token) {
|
|
http_response_code(401);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Missing Bearer token"]);
|
|
exit;
|
|
}
|
|
$rec = token_get_record($token);
|
|
if (!$rec) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Invalid or expired token"]);
|
|
exit;
|
|
}
|
|
if (!empty($rec['temp'])) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Password change required before access"]);
|
|
exit;
|
|
}
|
|
$rec['_token'] = $token;
|
|
return $rec;
|
|
}
|
|
|
|
function require_role(array $allowed, array $tokenRecord): void {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
if (!in_array($role, $allowed, true)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Insufficient permissions"]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a SQL WHERE clause fragment that restricts client visibility by role.
|
|
* Returns [string $clause, array $params].
|
|
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client').
|
|
*/
|
|
function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
$entityID = $tokenRecord['entityID'] ?? '';
|
|
switch ($role) {
|
|
case 'admin':
|
|
return ['1=1', []];
|
|
case 'supervisor':
|
|
return [
|
|
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
case 'coach':
|
|
return [
|
|
"$clientAlias.coachID = :rbac_eid",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
default:
|
|
return ['0=1', []];
|
|
}
|
|
}
|
|
|
|
// --- Translations: German (de) is the canonical source language ---
|
|
define('QDB_SOURCE_LANGUAGE', 'de');
|
|
|
|
function qdb_ensure_source_language(PDO $pdo): void {
|
|
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
|
ON CONFLICT(languageCode) DO NOTHING")
|
|
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
|
|
}
|
|
|
|
function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
|
|
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
|
|
}
|
|
|
|
/** Catalog of global app UI string keys (LanguageManager). */
|
|
function qdb_app_string_catalog(): array {
|
|
static $catalog = null;
|
|
if ($catalog !== null) {
|
|
return $catalog;
|
|
}
|
|
$path = __DIR__ . '/data/app_ui_strings.json';
|
|
if (!is_readable($path)) {
|
|
$catalog = ['keys' => [], 'germanDefaults' => []];
|
|
return $catalog;
|
|
}
|
|
$data = json_decode(file_get_contents($path), true);
|
|
$catalog = is_array($data) ? $data : ['keys' => [], 'germanDefaults' => []];
|
|
return $catalog;
|
|
}
|
|
|
|
function qdb_app_string_key_set(): array {
|
|
$keys = qdb_app_string_catalog()['keys'] ?? [];
|
|
return array_flip($keys);
|
|
}
|
|
|
|
/** Keys for dev-only UI (database export, etc.) — excluded from website App UI and app sync catalog. */
|
|
function qdb_dev_only_app_string_key_set(): array {
|
|
static $set = null;
|
|
if ($set !== null) {
|
|
return $set;
|
|
}
|
|
$set = array_flip([
|
|
'database',
|
|
'database_clients_title',
|
|
'download_header',
|
|
'export_success_downloads',
|
|
'export_failed',
|
|
'headers',
|
|
'view_missing',
|
|
'no_header_template_found',
|
|
'saved_pdf_csv',
|
|
'no_pdf_viewer',
|
|
'save_error',
|
|
'no_clients_available',
|
|
'questionnaire_id',
|
|
'questionnaires',
|
|
'status',
|
|
'data',
|
|
'data_final_warning',
|
|
'open_client_via_load',
|
|
'ask_before_upload',
|
|
'You have completed questionnaires that have not been uploaded yet.',
|
|
'You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.',
|
|
]);
|
|
return $set;
|
|
}
|
|
|
|
/**
|
|
* Global app UI strings (screens, toasts, auth, etc.) for the mobile app.
|
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
|
*/
|
|
function qdb_app_ui_string_entries(): array {
|
|
$catalog = qdb_app_string_catalog();
|
|
$defaults = $catalog['germanDefaults'] ?? [];
|
|
$entries = [];
|
|
$order = 0;
|
|
foreach ($catalog['keys'] ?? [] as $key) {
|
|
$entries[] = [
|
|
'key' => $key,
|
|
'type' => 'app_string',
|
|
'entityId' => $key,
|
|
'sortOrder' => $order++,
|
|
'germanDefault' => $defaults[$key] ?? $key,
|
|
];
|
|
}
|
|
return $entries;
|
|
}
|
|
|
|
/**
|
|
* All string keys referenced by questionnaire content (questions, options, config).
|
|
* @return array<string, true>
|
|
*/
|
|
function qdb_all_questionnaire_translation_key_set(PDO $pdo): array {
|
|
static $cache = null;
|
|
if ($cache !== null) {
|
|
return $cache;
|
|
}
|
|
|
|
$keys = [];
|
|
$rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC);
|
|
foreach ($rows as $dbQ) {
|
|
if ($dbQ['defaultText'] !== '') {
|
|
$keys[$dbQ['defaultText']] = true;
|
|
}
|
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
|
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
|
if (!empty($config[$field])) {
|
|
$keys[$config[$field]] = true;
|
|
}
|
|
}
|
|
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
|
foreach ($config['symptoms'] as $s) {
|
|
if ($s !== '') {
|
|
$keys[$s] = true;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($config['options']) && is_array($config['options'])) {
|
|
foreach ($config['options'] as $opt) {
|
|
if (is_string($opt) && $opt !== '') {
|
|
$keys[$opt] = true;
|
|
} elseif (is_array($opt) && !empty($opt['key'])) {
|
|
$keys[$opt['key']] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($pdo->query("SELECT defaultText FROM answer_option")->fetchAll(PDO::FETCH_COLUMN) as $text) {
|
|
if ($text !== '') {
|
|
$keys[$text] = true;
|
|
}
|
|
}
|
|
|
|
$cache = $keys;
|
|
return $cache;
|
|
}
|
|
|
|
/**
|
|
* App UI catalog for the website Translations page (coach-visible, no dev-only keys).
|
|
* Catalog keys always appear here even when also used inside a questionnaire (e.g. "save").
|
|
*/
|
|
function qdb_app_ui_string_entries_for_website(): array {
|
|
$dev = qdb_dev_only_app_string_key_set();
|
|
$entries = [];
|
|
foreach (qdb_app_ui_string_entries() as $e) {
|
|
if (!isset($dev[$e['key']])) {
|
|
$entries[] = $e;
|
|
}
|
|
}
|
|
return $entries;
|
|
}
|
|
|
|
/** Upsert one translation row; sync German text into defaultText for questions/options. */
|
|
function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void {
|
|
if ($type === 'question') {
|
|
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
if ($lang === QDB_SOURCE_LANGUAGE) {
|
|
$pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id")
|
|
->execute([':t' => $text, ':id' => $id]);
|
|
}
|
|
} elseif ($type === 'answer_option') {
|
|
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
if ($lang === QDB_SOURCE_LANGUAGE) {
|
|
$pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id")
|
|
->execute([':t' => $text, ':id' => $id]);
|
|
}
|
|
} elseif ($type === 'string' || $type === 'app_string') {
|
|
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
|
VALUES (:id, :lang, :t)
|
|
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
|
|
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
|
}
|
|
}
|
|
|
|
function qdb_require_non_empty_german(string $text, string $label = 'German text'): void {
|
|
if ($text === '') {
|
|
json_error('MISSING_FIELDS', "$label is required", 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build translation entry lists for one questionnaire.
|
|
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
|
*/
|
|
function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
|
$qStmt = $pdo->prepare("
|
|
SELECT q.questionID, q.defaultText, q.configJson, q.orderIndex
|
|
FROM question q WHERE q.questionnaireID = :id ORDER BY q.orderIndex
|
|
");
|
|
$qStmt->execute([':id' => $qnID]);
|
|
$dbQuestions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$stringKeys = [];
|
|
$stringEntries = [];
|
|
$contentEntries = [];
|
|
|
|
foreach ($dbQuestions as $dbQ) {
|
|
$qOrder = (int)($dbQ['orderIndex'] ?? 0);
|
|
$contentEntries[] = [
|
|
'key' => $dbQ['defaultText'],
|
|
'type' => 'question',
|
|
'entityId' => $dbQ['questionID'],
|
|
'sortOrder' => $qOrder * 1000,
|
|
];
|
|
|
|
$aoStmt = $pdo->prepare("
|
|
SELECT answerOptionID, defaultText, orderIndex
|
|
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
|
");
|
|
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
$contentEntries[] = [
|
|
'key' => $ao['defaultText'],
|
|
'type' => 'answer_option',
|
|
'entityId' => $ao['answerOptionID'],
|
|
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0),
|
|
];
|
|
}
|
|
|
|
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
|
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
|
if (!empty($config[$field])) {
|
|
$stringKeys[$config[$field]] = true;
|
|
}
|
|
}
|
|
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
|
|
foreach ($config['symptoms'] as $s) {
|
|
$stringKeys[$s] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$appKeySet = qdb_app_string_key_set();
|
|
$stringOrder = 0;
|
|
$skList = array_keys($stringKeys);
|
|
sort($skList, SORT_STRING);
|
|
foreach ($skList as $sk) {
|
|
if (isset($appKeySet[$sk])) {
|
|
continue;
|
|
}
|
|
$stringEntries[] = [
|
|
'key' => $sk,
|
|
'type' => 'string',
|
|
'entityId' => $sk,
|
|
'sortOrder' => $stringOrder++,
|
|
];
|
|
}
|
|
|
|
return ['stringEntries' => $stringEntries, 'contentEntries' => $contentEntries];
|
|
}
|
|
|
|
/** Attach translation texts and germanText to entry rows. */
|
|
function qdb_attach_translation_texts(array &$entries, array $translations): void {
|
|
foreach ($entries as &$e) {
|
|
$tr = $translations[$e['entityId']] ?? [];
|
|
$e['translations'] = (object)$tr;
|
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
|
if ($de !== '') {
|
|
$e['germanText'] = $de;
|
|
} elseif (!empty($e['germanDefault'])) {
|
|
$e['germanText'] = $e['germanDefault'];
|
|
} else {
|
|
$e['germanText'] = $e['key'];
|
|
}
|
|
unset($e['germanDefault']);
|
|
}
|
|
unset($e);
|
|
}
|
|
|
|
/** Load all translation rows for a flat entry list. */
|
|
function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
|
|
$translations = [];
|
|
|
|
$qIds = array_values(array_filter(array_map(
|
|
fn($e) => $e['type'] === 'question' ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($qIds) {
|
|
$ph = implode(',', array_fill(0, count($qIds), '?'));
|
|
$stmt = $pdo->prepare("SELECT questionID AS entityId, languageCode, text FROM question_translation WHERE questionID IN ($ph)");
|
|
$stmt->execute($qIds);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
$aoIds = array_values(array_filter(array_map(
|
|
fn($e) => $e['type'] === 'answer_option' ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($aoIds) {
|
|
$ph = implode(',', array_fill(0, count($aoIds), '?'));
|
|
$stmt = $pdo->prepare("SELECT answerOptionID AS entityId, languageCode, text FROM answer_option_translation WHERE answerOptionID IN ($ph)");
|
|
$stmt->execute($aoIds);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
$sKeys = array_values(array_filter(array_map(
|
|
fn($e) => ($e['type'] === 'string' || $e['type'] === 'app_string') ? $e['entityId'] : null,
|
|
$entries
|
|
)));
|
|
if ($sKeys) {
|
|
$ph = implode(',', array_fill(0, count($sKeys), '?'));
|
|
$stmt = $pdo->prepare("SELECT stringKey AS entityId, languageCode, text FROM string_translation WHERE stringKey IN ($ph)");
|
|
$stmt->execute($sKeys);
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$translations[$r['entityId']][$r['languageCode']] = $r['text'];
|
|
}
|
|
}
|
|
|
|
return $translations;
|
|
}
|
|
|
|
/**
|
|
* Flat language -> stringKey -> text map for global app UI strings only.
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
function qdb_build_app_translations_map(PDO $pdo): array {
|
|
$entries = qdb_app_ui_string_entries();
|
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
|
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
|
$result = [];
|
|
foreach ($entries as $e) {
|
|
$key = $e['key'];
|
|
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
|
|
$result[$lang][$key] = $text;
|
|
}
|
|
if (!isset($result[QDB_SOURCE_LANGUAGE][$key]) && !empty($defaults[$key])) {
|
|
$result[QDB_SOURCE_LANGUAGE][$key] = $defaults[$key];
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
|
|
* Excludes keys from the global app UI catalog.
|
|
* @return array<string, array<string, string>>
|
|
*/
|
|
function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array {
|
|
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
|
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
|
if (!$entries) {
|
|
return [];
|
|
}
|
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
|
$result = [];
|
|
foreach ($entries as $e) {
|
|
$key = $e['key'];
|
|
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
|
|
$result[$lang][$key] = $text;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
|
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = random_bytes(16);
|
|
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($cipher === false) throw new Exception('openssl_encrypt failed');
|
|
return $iv . $cipher;
|
|
}
|
|
function aes256_cbc_decrypt_bytes(string $data, string $key): string {
|
|
if (strlen($data) < 16) throw new Exception('cipher too short');
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = substr($data, 0, 16);
|
|
$ct = substr($data, 16);
|
|
$plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($plain === false) throw new Exception('openssl_decrypt failed');
|
|
return $plain;
|
|
}
|