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;
|
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).
|
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
|
||||||
* Excludes keys from the global app UI catalog.
|
* Excludes keys from the global app UI catalog.
|
||||||
|
|||||||
@ -348,7 +348,11 @@ Common failures:
|
|||||||
|
|
||||||
`GET /api/app_questionnaires?translations=1`
|
`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:
|
Success:
|
||||||
|
|
||||||
@ -392,15 +396,16 @@ Android should apply this map while a questionnaire is open (overlay on top of a
|
|||||||
|
|
||||||
## Recommended Android Sync Flow
|
## 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).
|
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).
|
3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1` (Bearer token required) and cache locally.
|
||||||
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
|
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`).
|
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.
|
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).
|
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
|
## Android local data security
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
* Android app API endpoint.
|
* Android app API endpoint.
|
||||||
* GET (no params) -> ordered questionnaire list with conditions
|
* GET (no params) -> ordered questionnaire list with conditions
|
||||||
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
|
* 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 ?clients=1 -> list of clients assigned to the authenticated coach
|
||||||
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
|
* 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)
|
* 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).
|
* 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).
|
// Public login-screen catalog (auth + help_auth only; no PII).
|
||||||
if ($method === 'GET' && !empty($_GET['translations'])) {
|
if ($method === 'GET' && !empty($_GET['loginTranslations'])) {
|
||||||
$opened = qdb_open_read_or_fail();
|
$opened = qdb_open_read_or_fail();
|
||||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
json_success([
|
$payload = [
|
||||||
'translations' => qdb_build_app_translations_map($pdo),
|
'translations' => qdb_build_public_login_translations_map($pdo),
|
||||||
'availableLanguages' => qdb_available_language_codes($pdo),
|
'availableLanguages' => array_values(array_intersect(
|
||||||
]);
|
qdb_available_language_codes($pdo),
|
||||||
|
['de', 'en'],
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
if ($payload['availableLanguages'] === []) {
|
||||||
|
$payload['availableLanguages'] = ['de', 'en'];
|
||||||
|
}
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
return;
|
json_success($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tokenRec = require_valid_token();
|
$tokenRec = require_valid_token();
|
||||||
@ -393,6 +400,15 @@ $fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
|
|||||||
$fetchScoringReview = $_GET['scoringReview'] ?? '';
|
$fetchScoringReview = $_GET['scoringReview'] ?? '';
|
||||||
$clientCode = trim($_GET['clientCode'] ?? '');
|
$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) {
|
if ($fetchScoringProfiles) {
|
||||||
require_role(['coach'], $tokenRec);
|
require_role(['coach'], $tokenRec);
|
||||||
require_once __DIR__ . '/../lib/scoring.php';
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
|||||||
@ -85,9 +85,55 @@ final class CatalogApiTest extends QdbTestCase
|
|||||||
$this->assertArrayHasKey('entries', $res['data']);
|
$this->assertArrayHasKey('entries', $res['data']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAppTranslationsPublicNoAuth(): void
|
public function testAppTranslationsRequiresAuth(): void
|
||||||
{
|
{
|
||||||
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
|
$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->assertApiOk($res);
|
||||||
$this->assertArrayHasKey('translations', $res['data']);
|
$this->assertArrayHasKey('translations', $res['data']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user