diff --git a/common.php b/common.php index 671eca2..75916dd 100644 --- a/common.php +++ b/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> + */ +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. diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index d7c5f04..eabcb1b 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -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 `. +**Authentication:** required (`Authorization: Bearer `). 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=` (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 diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 0dc6ee6..8595bfa 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -3,7 +3,8 @@ * Android app API endpoint. * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> 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'; diff --git a/tests/Integration/CatalogApiTest.php b/tests/Integration/CatalogApiTest.php index 75ea251..f67d626 100644 --- a/tests/Integration/CatalogApiTest.php +++ b/tests/Integration/CatalogApiTest.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']); }