This commit is contained in:
52
common.php
52
common.php
@ -1959,6 +1959,58 @@ function qdb_build_app_translations_map(PDO $pdo): array {
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys safe to expose without auth (login screen + auth help only).
|
||||
*/
|
||||
function qdb_is_public_login_translation_key(string $key): bool {
|
||||
if (str_starts_with($key, 'auth_') || str_starts_with($key, 'help_auth_')) {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($key, 'password_rule_')) {
|
||||
return true;
|
||||
}
|
||||
if (preg_match('/^language_(german|english|de|en)$/i', $key) === 1) {
|
||||
return true;
|
||||
}
|
||||
static $exact = [
|
||||
'username_hint',
|
||||
'password_hint',
|
||||
'new_password_hint',
|
||||
'confirm_password_hint',
|
||||
'login_btn',
|
||||
'save_password_btn',
|
||||
'exit_btn',
|
||||
'language',
|
||||
'please_username_password',
|
||||
'login_failed_with_reason',
|
||||
'login_required',
|
||||
'passwords_dont_match',
|
||||
'password_needs_prefix',
|
||||
'password_too_short',
|
||||
];
|
||||
return in_array($key, $exact, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public subset of app UI strings for the login screen (auth + help_auth).
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
function qdb_build_public_login_translations_map(PDO $pdo): array {
|
||||
$full = qdb_build_app_translations_map($pdo);
|
||||
$filtered = [];
|
||||
foreach ($full as $lang => $strings) {
|
||||
if (!is_array($strings)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($strings as $key => $text) {
|
||||
if (qdb_is_public_login_translation_key((string)$key)) {
|
||||
$filtered[$lang][(string)$key] = (string)$text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
|
||||
* Excludes keys from the global app UI catalog.
|
||||
|
||||
@ -348,7 +348,11 @@ Common failures:
|
||||
|
||||
`GET /api/app_questionnaires?translations=1`
|
||||
|
||||
**Authentication:** not required. The Android app calls this on the login screen before the coach signs in. All other `app_questionnaires` routes still require `Authorization: Bearer <token>`.
|
||||
**Authentication:** required (`Authorization: Bearer <token>`). Fetched during post-login sync.
|
||||
|
||||
`GET /api/app_questionnaires?loginTranslations=1`
|
||||
|
||||
**Authentication:** not required. Returns only login-screen keys (`auth_*`, `help_auth_*`, password hints/rules, and related buttons). The app fetches this on the login screen; built-in DE/EN fallbacks still apply if offline.
|
||||
|
||||
Success:
|
||||
|
||||
@ -392,15 +396,16 @@ Android should apply this map while a questionnaire is open (overlay on top of a
|
||||
|
||||
## Recommended Android Sync Flow
|
||||
|
||||
1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally.
|
||||
1. On the login screen, optionally fetch `GET /api/app_questionnaires?loginTranslations=1` (no token) and cache locally; built-in DE/EN strings cover offline.
|
||||
2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android).
|
||||
3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
|
||||
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
|
||||
3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1` (Bearer token required) and cache locally.
|
||||
4. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
|
||||
5. Fetch the active questionnaire list with `GET /api/app_questionnaires`.
|
||||
6. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
|
||||
7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
|
||||
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` using an **encrypted** JSON envelope (see above).
|
||||
|
||||
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.
|
||||
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details when syncing with the server.
|
||||
|
||||
## Android local data security
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
* Android app API endpoint.
|
||||
* GET (no params) -> ordered questionnaire list with conditions
|
||||
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
|
||||
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
|
||||
* GET ?translations=1 -> global app UI strings only (not questionnaire content); requires Bearer token
|
||||
* GET ?loginTranslations=1 -> login + auth-help UI strings only; **no auth** (pre-login screen)
|
||||
* GET ?clients=1 -> list of clients assigned to the authenticated coach
|
||||
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
|
||||
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
|
||||
@ -16,16 +17,22 @@
|
||||
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
||||
*/
|
||||
|
||||
// Public app UI catalog for the login screen (no token, no PII).
|
||||
if ($method === 'GET' && !empty($_GET['translations'])) {
|
||||
// Public login-screen catalog (auth + help_auth only; no PII).
|
||||
if ($method === 'GET' && !empty($_GET['loginTranslations'])) {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
json_success([
|
||||
'translations' => qdb_build_app_translations_map($pdo),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo),
|
||||
]);
|
||||
$payload = [
|
||||
'translations' => qdb_build_public_login_translations_map($pdo),
|
||||
'availableLanguages' => array_values(array_intersect(
|
||||
qdb_available_language_codes($pdo),
|
||||
['de', 'en'],
|
||||
)),
|
||||
];
|
||||
if ($payload['availableLanguages'] === []) {
|
||||
$payload['availableLanguages'] = ['de', 'en'];
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return;
|
||||
json_success($payload);
|
||||
}
|
||||
|
||||
$tokenRec = require_valid_token();
|
||||
@ -393,6 +400,15 @@ $fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
|
||||
$fetchScoringReview = $_GET['scoringReview'] ?? '';
|
||||
$clientCode = trim($_GET['clientCode'] ?? '');
|
||||
|
||||
if ($translations) {
|
||||
$payload = [
|
||||
'translations' => qdb_build_app_translations_map($pdo),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo),
|
||||
];
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success($payload);
|
||||
}
|
||||
|
||||
if ($fetchScoringProfiles) {
|
||||
require_role(['coach'], $tokenRec);
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
|
||||
@ -85,9 +85,55 @@ final class CatalogApiTest extends QdbTestCase
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
|
||||
public function testAppTranslationsPublicNoAuth(): void
|
||||
public function testAppTranslationsRequiresAuth(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
public function testAppLoginTranslationsPublicNoAuth(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['loginTranslations' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('translations', $res['data']);
|
||||
$map = $res['data']['translations'];
|
||||
$this->assertIsArray($map);
|
||||
// Must include auth / help_auth keys and must not dump unrelated catalog keys.
|
||||
$foundAuth = false;
|
||||
$foundHelp = false;
|
||||
$forbidden = false;
|
||||
foreach ($map as $lang => $strings) {
|
||||
$this->assertIsArray($strings);
|
||||
foreach ($strings as $key => $text) {
|
||||
$this->assertTrue(
|
||||
qdb_is_public_login_translation_key((string)$key),
|
||||
"Unexpected public key: {$key}"
|
||||
);
|
||||
if (str_starts_with((string)$key, 'auth_')) {
|
||||
$foundAuth = true;
|
||||
}
|
||||
if (str_starts_with((string)$key, 'help_auth_')) {
|
||||
$foundHelp = true;
|
||||
}
|
||||
if ((string)$key === 'client_code' || (string)$key === 'client_picker_title') {
|
||||
$forbidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->assertTrue($foundAuth);
|
||||
$this->assertTrue($foundHelp);
|
||||
$this->assertFalse($forbidden);
|
||||
}
|
||||
|
||||
public function testAppTranslationsWithToken(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'translations' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('translations', $res['data']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user