3 Komitmen bf1abc1cda ... 1952f262cd

Pembuat SHA1 Pesan Tanggal
  Medowar 1952f262cd improving api texts 2 minggu lalu
  Medowar 94997175c1 Merge branch 'main' into new-intro-flow 2 minggu lalu
  Medowar d6a22b2e3e moved all messages to config 2 minggu lalu
9 mengubah file dengan 242 tambahan dan 91 penghapusan
  1. 33 12
      api/delete-upload.php
  2. 22 7
      api/load-draft.php
  3. 27 9
      api/reset.php
  4. 29 11
      api/save-draft.php
  5. 32 18
      api/submit.php
  6. 8 32
      api/upload-preview.php
  7. 53 1
      config/app.sample.php
  8. 8 1
      index.php
  9. 30 0
      src/app/bootstrap.php

+ 33 - 12
api/delete-upload.php

@@ -13,21 +13,33 @@ require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.method_not_allowed'),
+    ], 405);
 }
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 400);
 }
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
 $activityRaw = $_POST['last_user_activity_at'] ?? null;
@@ -47,7 +59,10 @@ if (($auth['ok'] ?? false) !== true) {
 $field = trim((string) ($_POST['field'] ?? ''));
 $index = (int) ($_POST['index'] ?? -1);
 if ($field === '' || $index < 0) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiger Upload-Eintrag.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('delete_upload.invalid_upload_entry'),
+    ], 422);
 }
 
 /** @return array{path: string, dir: string}|null */
@@ -96,7 +111,10 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('delete-upload:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('delete_upload.rate_limited'),
+    ], 429);
 }
 
 $store = new JsonStore();
@@ -107,7 +125,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 409,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+                'message' => Bootstrap::appMessage('delete_upload.already_submitted'),
             ];
         }
 
@@ -116,7 +134,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 404,
-                'message' => 'Kein Entwurf gefunden.',
+                'message' => Bootstrap::appMessage('delete_upload.draft_not_found'),
             ];
         }
 
@@ -126,7 +144,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 404,
-                'message' => 'Upload nicht gefunden.',
+                'message' => Bootstrap::appMessage('delete_upload.upload_not_found'),
             ];
         }
 
@@ -170,19 +188,22 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Upload konnte nicht gelöscht werden.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('delete_upload.delete_error'),
+    ], 500);
 }
 
 if (($result['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => (string) ($result['message'] ?? 'Upload konnte nicht gelöscht werden.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('delete_upload.delete_error')),
     ], (int) ($result['status'] ?? 422));
 }
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Upload gelöscht.',
+    'message' => Bootstrap::appMessage('delete_upload.success'),
     'uploads' => $result['uploads'] ?? [],
     'updated_at' => $result['updated_at'] ?? null,
 ]);

+ 22 - 7
api/load-draft.php

@@ -3,31 +3,43 @@
 declare(strict_types=1);
 
 use App\App\Bootstrap;
-use App\Storage\JsonStore;
 use App\Security\Csrf;
 use App\Security\FormAccess;
 use App\Security\RateLimiter;
+use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.method_not_allowed'),
+    ], 405);
 }
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 $honeypot = trim((string) ($_POST['website'] ?? ''));
 if ($honeypot !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 400);
 }
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
 $activityRaw = $_POST['last_user_activity_at'] ?? null;
@@ -49,7 +61,10 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('load:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte später erneut versuchen.'], 429);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('load_draft.rate_limited'),
+    ], 429);
 }
 
 $store = new JsonStore();
@@ -58,7 +73,7 @@ if ($submission !== null) {
     Bootstrap::jsonResponse([
         'ok' => true,
         'already_submitted' => true,
-        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        'message' => Bootstrap::appMessage('load_draft.already_submitted'),
     ]);
 }
 

+ 27 - 9
api/reset.php

@@ -13,21 +13,33 @@ require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.method_not_allowed'),
+    ], 405);
 }
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 400);
 }
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
 $activityRaw = $_POST['last_user_activity_at'] ?? null;
@@ -49,7 +61,10 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('reset:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('reset.rate_limited'),
+    ], 429);
 }
 
 $store = new JsonStore();
@@ -64,7 +79,7 @@ try {
             return [
                 'ok' => false,
                 'status' => 409,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
+                'message' => Bootstrap::appMessage('reset.already_submitted'),
                 'had_draft' => $hadDraft,
                 'had_submission' => true,
             ];
@@ -84,13 +99,16 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'reset error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Daten konnten nicht gelöscht werden.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('reset.delete_error'),
+    ], 500);
 }
 
 if (($result['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => (string) ($result['message'] ?? 'Daten konnten nicht gelöscht werden.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('reset.delete_error')),
         'had_draft' => (bool) ($result['had_draft'] ?? false),
         'had_submission' => (bool) ($result['had_submission'] ?? false),
     ], (int) ($result['status'] ?? 422));
@@ -98,7 +116,7 @@ if (($result['ok'] ?? false) !== true) {
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Gespeicherte Daten wurden gelöscht.',
+    'message' => Bootstrap::appMessage('reset.success'),
     'had_draft' => (bool) ($result['had_draft'] ?? false),
     'had_submission' => (bool) ($result['had_submission'] ?? false),
 ]);

+ 29 - 11
api/save-draft.php

@@ -4,31 +4,43 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Form\FormSchema;
-use App\Storage\FileUploadStore;
-use App\Storage\JsonStore;
 use App\Security\Csrf;
 use App\Security\FormAccess;
 use App\Security\RateLimiter;
+use App\Storage\FileUploadStore;
+use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.method_not_allowed'),
+    ], 405);
 }
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 400);
 }
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
 $activityRaw = $_POST['last_user_activity_at'] ?? null;
@@ -50,7 +62,10 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('save:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Speicheranfragen.'], 429);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('save_draft.rate_limited'),
+    ], 429);
 }
 
 $step = (int) ($_POST['step'] ?? 1);
@@ -72,7 +87,7 @@ try {
         if ($store->hasSubmission($email)) {
             return [
                 'blocked' => true,
-                'message' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
+                'message' => Bootstrap::appMessage('save_draft.already_submitted'),
             ];
         }
 
@@ -87,14 +102,17 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'save-draft lock error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Speichern derzeit nicht möglich.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('save_draft.lock_error'),
+    ], 500);
 }
 
 if (($result['blocked'] ?? false) === true) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => true,
-        'message' => (string) ($result['message'] ?? 'Bereits abgeschlossen.'),
+        'message' => (string) ($result['message'] ?? Bootstrap::appMessage('save_draft.blocked_fallback')),
     ], 409);
 }
 
@@ -124,7 +142,7 @@ $draft = $store->getDraft($email);
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'Entwurf gespeichert.',
+    'message' => Bootstrap::appMessage('save_draft.success'),
     'updated_at' => $draft['updated_at'] ?? null,
     'upload_errors' => $uploadResult['errors'],
     'uploads' => $draft['uploads'] ?? [],

+ 32 - 18
api/submit.php

@@ -6,11 +6,11 @@ use App\App\Bootstrap;
 use App\Form\FormSchema;
 use App\Form\Validator;
 use App\Mail\Mailer;
-use App\Storage\FileUploadStore;
-use App\Storage\JsonStore;
 use App\Security\Csrf;
 use App\Security\FormAccess;
 use App\Security\RateLimiter;
+use App\Storage\FileUploadStore;
+use App\Storage\JsonStore;
 
 require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
@@ -18,11 +18,7 @@ Bootstrap::init();
 /** @param array<string, mixed> $app */
 function resolveSubmitSuccessMessage(array $app): string
 {
-    $fallback = 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.';
-    $configured = trim((string) ($app['submission_success_message'] ?? $fallback));
-    if ($configured === '') {
-        $configured = $fallback;
-    }
+    $configured = Bootstrap::appMessage('submit.success');
 
     $contactEmail = trim((string) ($app['contact_email'] ?? ''));
     $message = str_replace(
@@ -35,21 +31,33 @@ function resolveSubmitSuccessMessage(array $app): string
 }
 
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.method_not_allowed'),
+    ], 405);
 }
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_csrf'),
+    ], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.request_blocked'),
+    ], 400);
 }
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('common.invalid_email'),
+    ], 422);
 }
 
 $activityRaw = $_POST['last_user_activity_at'] ?? null;
@@ -71,7 +79,10 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('submit:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen.'], 429);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('submit.rate_limited'),
+    ], 429);
 }
 
 $formDataRaw = $_POST['form_data'] ?? [];
@@ -95,7 +106,7 @@ if ($store->hasSubmission($email)) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => true,
-        'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        'message' => Bootstrap::appMessage('submit.already_submitted'),
     ], 409);
 }
 
@@ -105,7 +116,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => true,
-                'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+                'message' => Bootstrap::appMessage('submit.already_submitted'),
             ];
         }
 
@@ -114,7 +125,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => false,
-                'message' => 'Fehler bei Uploads.',
+                'message' => Bootstrap::appMessage('submit.upload_error'),
                 'errors' => $uploadResult['errors'],
             ];
         }
@@ -138,7 +149,7 @@ try {
             return [
                 'ok' => false,
                 'already_submitted' => false,
-                'message' => 'Bitte Pflichtfelder prüfen.',
+                'message' => Bootstrap::appMessage('submit.validation_error'),
                 'errors' => $errors,
             ];
         }
@@ -156,7 +167,10 @@ try {
     });
 } catch (Throwable $e) {
     Bootstrap::log('app', 'submit lock error: ' . $e->getMessage());
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Abschluss derzeit nicht möglich.'], 500);
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => Bootstrap::appMessage('submit.lock_error'),
+    ], 500);
 }
 
 if (($submitResult['ok'] ?? false) !== true) {
@@ -164,7 +178,7 @@ if (($submitResult['ok'] ?? false) !== true) {
     Bootstrap::jsonResponse([
         'ok' => false,
         'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
-        'message' => (string) ($submitResult['message'] ?? 'Abschluss fehlgeschlagen.'),
+        'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'errors' => $submitResult['errors'] ?? [],
     ], $status);
 }

+ 8 - 32
api/upload-preview.php

@@ -12,26 +12,17 @@ require dirname(__DIR__) . '/src/autoload.php';
 Bootstrap::init();
 
 if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
-    http_response_code(405);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Method not allowed';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.method_not_allowed'), 405);
 }
 
 $csrf = $_GET['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    http_response_code(419);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Ungültiges CSRF-Token.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_csrf'), 419);
 }
 
 $email = strtolower(trim((string) ($_GET['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    http_response_code(422);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Bitte gültige E-Mail eingeben.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_email'), 422);
 }
 
 $activityRaw = $_GET['last_user_activity_at'] ?? null;
@@ -51,10 +42,7 @@ if (($auth['ok'] ?? false) !== true) {
 $field = trim((string) ($_GET['field'] ?? ''));
 $index = (int) ($_GET['index'] ?? -1);
 if ($field === '' || $index < 0) {
-    http_response_code(422);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Ungültiger Upload-Eintrag.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.invalid_upload_entry'), 422);
 }
 
 /** @return string|null */
@@ -95,38 +83,26 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('preview-upload:%s:%s', $ip, $email);
 if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
-    http_response_code(429);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Zu viele Anfragen. Bitte später erneut versuchen.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.rate_limited'), 429);
 }
 
 $store = new JsonStore();
 $draft = $store->getDraft($email);
 
 if (!is_array($draft)) {
-    http_response_code(404);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Entwurf nicht gefunden.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.draft_not_found'), 404);
 }
 
 $uploads = (array) ($draft['uploads'] ?? []);
 $files = $uploads[$field] ?? null;
 $entry = (is_array($files) && isset($files[$index]) && is_array($files[$index])) ? $files[$index] : null;
 if (!is_array($entry)) {
-    http_response_code(404);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Upload nicht gefunden.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.upload_not_found'), 404);
 }
 
 $path = resolveStoredPreviewPath($entry, $app);
 if ($path === null || !is_file($path)) {
-    http_response_code(404);
-    header('Content-Type: text/plain; charset=utf-8');
-    echo 'Datei nicht gefunden.';
-    exit;
+    Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.file_not_found'), 404);
 }
 
 $mime = (string) ($entry['mime'] ?? '');

+ 53 - 1
config/app.sample.php

@@ -8,7 +8,59 @@ return [
     'project_name' => 'Feuerwehr Mitgliedsantrag',
     'base_url' => '/',
     'contact_email' => 'kontakt@example.org',
-    'submission_success_message' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
+    'api_messages' => [
+        'common' => [
+            'method_not_allowed' => 'Method not allowed',
+            'invalid_csrf' => 'Invalid CSRF-Token.',
+            'request_blocked' => 'Anfrage blockiert.',
+            'invalid_email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
+        ],
+        'load_draft' => [
+            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+        ],
+        'save_draft' => [
+            'rate_limited' => 'Ratelimited. Zu viele Speicheranfragen.',
+            'already_submitted' => 'Für diese E-Mail wurde bereits ein Antrag abgeschlossen.',
+            'lock_error' => 'Speichern derzeit nicht möglich.',
+            'blocked_fallback' => 'Bereits abgeschlossen.',
+            'success' => 'Entwurf gespeichert.',
+        ],
+        'submit' => [
+            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            'upload_error' => 'Fehler bei Uploads.',
+            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfeler sind ausgefüllt oder ungültige Werte vorhanden.',
+            'lock_error' => 'Abschluss derzeit nicht möglich. Debug-Info: Lock konnte nicht gesetzt werden.',
+            'failure' => 'Abschluss fehlgeschlagen.',
+            'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
+        ],
+        'reset' => [
+            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor. Ein Zurücksetzen ist nicht möglich.',
+            'delete_error' => 'Daten konnten nicht gelöscht werden.',
+            'success' => 'Gespeicherte Daten wurden gelöscht.',
+        ],
+        'delete_upload' => [
+            'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
+            'rate_limited' => 'Ratelimited. Zu viele Löschanfragen. Bitte später erneut versuchen.',
+            'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
+            'draft_not_found' => 'Kein Entwurf gefunden.',
+            'upload_not_found' => 'Upload nicht gefunden.',
+            'delete_error' => 'Upload konnte nicht gelöscht werden.',
+            'success' => 'Upload gelöscht.',
+        ],
+        'upload_preview' => [
+            'invalid_upload_entry' => 'Ungültiger Upload-Eintrag.',
+            'rate_limited' => 'Ratelimited. Zu viele Anfragen. Bitte später erneut versuchen.',
+            'draft_not_found' => 'Entwurf nicht gefunden.',
+            'upload_not_found' => 'Upload nicht gefunden.',
+            'file_not_found' => 'Datei nicht gefunden.',
+        ],
+    ],
+    'start' => [
+        'intro_text' => 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.',
+    ],
     'disclaimer' => [
         'title' => 'Wichtiger Hinweis',
         'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfaeltig.\n\nMit dem Fortfahren bestaetigen Sie, dass Ihre Angaben vollstaendig und wahrheitsgemaess sind.\nIhre Daten werden ausschliesslich zur Bearbeitung des Mitgliedsantrags verwendet.",

+ 8 - 1
index.php

@@ -25,6 +25,13 @@ if (is_string($disclaimerConfigRaw)) {
 $disclaimerTitle = (string) ($disclaimerConfig['title'] ?? 'Hinweis');
 $disclaimerText = (string) ($disclaimerConfig['text'] ?? '');
 $disclaimerAcceptLabel = (string) ($disclaimerConfig['accept_label'] ?? 'Hinweis gelesen, weiter');
+$startConfigRaw = $app['start'] ?? [];
+if (is_array($startConfigRaw)) {
+    $startConfig = $startConfigRaw;
+} else {
+    $startConfig = [];
+}
+$startIntroText = (string) ($startConfig['intro_text'] ?? 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.');
 $addressDisclaimerConfigRaw = $app['address_disclaimer'] ?? ($app['address_disclaimer_text'] ?? '');
 if (is_string($addressDisclaimerConfigRaw)) {
     $addressDisclaimerText = $addressDisclaimerConfigRaw;
@@ -269,7 +276,7 @@ function renderField(array $field, string $addressDisclaimerText): void
 
     <section id="startSection" class="card">
         <h2>Status</h2>
-        <p id="startIntroText">Bitte E-Mail eingeben. Danach senden wir einen 6-stelligen Sicherheitscode.</p>
+        <p id="startIntroText"><?= htmlspecialchars($startIntroText) ?></p>
         <form id="startForm" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <div class="hp-field" aria-hidden="true">

+ 30 - 0
src/app/bootstrap.php

@@ -74,6 +74,28 @@ final class Bootstrap
         return ($base !== '' ? $base : '') . '/' . $normalizedPath;
     }
 
+    public static function appMessage(string $path, string $fallback = ''): string
+    {
+        $value = self::config('app')['api_messages'] ?? [];
+        if (!is_array($value)) {
+            return $fallback;
+        }
+
+        foreach (explode('.', $path) as $segment) {
+            if (!is_array($value) || !array_key_exists($segment, $value)) {
+                return $fallback;
+            }
+            $value = $value[$segment];
+        }
+
+        if (!is_string($value)) {
+            return $fallback;
+        }
+
+        $message = trim($value);
+        return $message !== '' ? $message : $fallback;
+    }
+
     /** @param array<string, mixed> $payload */
     public static function jsonResponse(array $payload, int $statusCode = 200): void
     {
@@ -83,6 +105,14 @@ final class Bootstrap
         exit;
     }
 
+    public static function textResponse(string $message, int $statusCode = 200): void
+    {
+        http_response_code($statusCode);
+        header('Content-Type: text/plain; charset=utf-8');
+        echo $message;
+        exit;
+    }
+
     public static function log(string $channel, string $message): void
     {
         $app = self::config('app');