Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung

- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos
  bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4)
  gewandelt, Originale werden mit Range-Unterstützung gestreamt.
- Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später.
- Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein.
  Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen.
- Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag.
- Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen,
  die Safari nicht abspielen kann.
- Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 07:00:35 +00:00
parent f79dcb9928
commit b262a6771f
59 changed files with 11879 additions and 197 deletions

View file

@ -3,9 +3,22 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Failed login attempts allowed per throttle key before locking out.
*/
private const MAX_ATTEMPTS = 5;
/**
* Lockout duration in seconds once the attempt limit is exceeded.
*/
private const DECAY_SECONDS = 60;
public function authorize(): bool
{
return true;
@ -23,4 +36,53 @@ class LoginRequest extends FormRequest
'password' => ['required', 'string'],
];
}
/**
* Reject the request once too many failed attempts came from this
* identity/IP pair, before any password hash is ever computed.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), self::MAX_ATTEMPTS)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
])->status(429);
}
/**
* Record a failed attempt against this identity/IP pair.
*/
public function hitRateLimiter(): void
{
RateLimiter::hit($this->throttleKey(), self::DECAY_SECONDS);
}
/**
* Clear the counter after a successful login.
*/
public function clearRateLimiter(): void
{
RateLimiter::clear($this->throttleKey());
}
/**
* Throttle per normalised e-mail *and* IP, so one attacker cannot lock
* out an unrelated account and one IP cannot cycle through accounts.
*/
public function throttleKey(): string
{
return Str::transliterate(
'api-login|'.Str::lower((string) $this->input('email')).'|'.$this->ip()
);
}
}

View file

@ -6,6 +6,19 @@ use Illuminate\Foundation\Http\FormRequest;
class StoreEventMediaRequest extends FormRequest
{
/** Images accepted everywhere an image is allowed. */
private const IMAGE_MIMES = 'jpg,jpeg,png,webp';
/** Container formats phones and browsers actually produce. */
private const VIDEO_MIMETYPES = 'video/mp4,video/quicktime,video/webm,video/x-m4v';
/**
* MediaRecorder output differs per browser: Chrome and Firefox record
* audio into a WebM container, which libmagic reports as video/webm;
* Safari records audio/mp4 (often sniffed as audio/x-m4a or video/mp4).
*/
private const AUDIO_MIMETYPES = 'audio/webm,video/webm,audio/ogg,application/ogg,audio/mp4,audio/x-m4a,video/mp4,audio/mpeg,audio/wav,audio/x-wav,audio/aac';
public function authorize(): bool
{
return true;
@ -14,13 +27,44 @@ class StoreEventMediaRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* The accepted file type depends on the collection: the key image stays
* an image, the gallery takes images and videos, and a voice note is
* audio only.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$file = match ($this->input('collection', 'gallery')) {
'voice_note' => ['required', 'file', 'mimetypes:'.self::AUDIO_MIMETYPES, 'max:20480'],
'gallery' => [
'required',
'file',
'mimetypes:image/jpeg,image/png,image/webp,'.self::VIDEO_MIMETYPES,
'max:102400',
],
default => ['required', 'image', 'mimes:'.self::IMAGE_MIMES, 'max:10240'],
};
return [
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'collection' => ['nullable', 'in:key_image,gallery'],
'file' => $file,
'collection' => ['nullable', 'in:key_image,gallery,voice_note'],
// Sent along with the file when the note was written before the
// upload could happen (offline), so it needs no second request.
'caption' => ['nullable', 'string', 'max:500'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'file.mimetypes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
'file.mimes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
'file.image' => 'Für das Key Image ist nur ein Bild erlaubt.',
'file.max' => 'Die Datei ist zu groß.',
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Support\EventFields;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventRequest extends FormRequest
@ -15,13 +16,7 @@ class StoreEventRequest extends FormRequest
{
return [
'id' => ['required', 'uuid', 'unique:events,client_id'],
'title' => ['required', 'string', 'max:255'],
'date' => ['required', 'date_format:Y-m-d'],
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
...EventFields::storeRules(),
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'caption' => ['present', 'nullable', 'string', 'max:500'],
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Support\EventFields;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventRequest extends FormRequest
@ -13,14 +14,6 @@ class UpdateEventRequest extends FormRequest
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
];
return EventFields::updateRules();
}
}

View file

@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Support\SettingsSchema;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class UpdateSettingsRequest extends FormRequest
{
@ -17,21 +19,59 @@ class UpdateSettingsRequest extends FormRequest
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return SettingsSchema::rules();
}
/**
* Reject keys the schema does not know about instead of persisting them.
*
* @return array<int, callable>
*/
public function after(): array
{
return [
'settings' => ['required', 'array'],
'settings.theme' => ['nullable', 'string', 'max:50'],
'settings.floatingLines' => ['nullable', 'array'],
'settings.appearance' => ['nullable', 'string', 'max:50'],
'settings.accentColor' => ['nullable', 'string', 'max:50'],
'settings.language' => ['nullable', 'string', 'max:10'],
'settings.emotionGradientStart' => ['nullable', 'string', 'max:20'],
'settings.emotionGradientEnd' => ['nullable', 'string', 'max:20'],
'settings.timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'],
'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'],
'settings.showFps' => ['nullable', 'boolean'],
'settings.presets' => ['nullable', 'array'],
'settings.activePresetId' => ['nullable', 'string', 'max:100'],
function (Validator $validator): void {
$this->rejectUnknownKeys(
$validator,
'settings',
(array) $this->input('settings', []),
SettingsSchema::allowedKeys(),
);
$floatingLines = $this->input('settings.floatingLines');
if (is_array($floatingLines)) {
$this->rejectUnknownKeys(
$validator,
'settings.floatingLines',
$floatingLines,
SettingsSchema::allowedFloatingLineKeys(),
);
}
},
];
}
/**
* @param array<string, mixed> $given
* @param array<int, string> $allowed
*/
private function rejectUnknownKeys(
Validator $validator,
string $attribute,
array $given,
array $allowed,
): void {
$unknown = array_diff(array_keys($given), $allowed);
if ($unknown === []) {
return;
}
$validator->errors()->add(
$attribute,
'Unknown settings keys: '.implode(', ', array_map('strval', $unknown)).'.',
);
}
}