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:
parent
f79dcb9928
commit
b262a6771f
59 changed files with 11879 additions and 197 deletions
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ß.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
backend/app/Http/Requests/UpdateEventMediaRequest.php
Normal file
23
backend/app/Http/Requests/UpdateEventMediaRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)).'.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue