APP als Hybrid Version - Anbindung an API

This commit is contained in:
Kevin Adametz 2026-06-05 09:54:12 +02:00
parent d054732bf5
commit c1514999be
46 changed files with 3418 additions and 196 deletions

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = User::query()
->where('email', $credentials['email'])
->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$token = $user->createToken('quasar-app')->accessToken;
return response()->json([
'token' => $token,
'tokenType' => 'Bearer',
'user' => [
'id' => (string) $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $this->avatarFor($user),
'mode' => 'remote',
],
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()?->token()?->revoke();
return response()->json(null, 204);
}
private function avatarFor(User $user): string
{
return substr(strtoupper($user->initials()), 0, 3);
}
}

View file

@ -18,7 +18,7 @@ class EventController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->user()->events()->orderBy('date');
$query = $request->user()->events()->with('media')->orderBy('date');
// Delta sync: only events updated since a given timestamp
if ($request->has('since')) {
@ -50,7 +50,7 @@ class EventController extends Controller
'note' => $request->validated('note'),
]);
return (new EventResource($event))
return (new EventResource($event->load('media')))
->response()
->setStatusCode(201);
}
@ -64,7 +64,7 @@ class EventController extends Controller
->where('client_id', $clientId)
->firstOrFail();
return new EventResource($event);
return new EventResource($event->load('media'));
}
/**
@ -103,7 +103,7 @@ class EventController extends Controller
$event->update($data);
return new EventResource($event->fresh());
return new EventResource($event->fresh()->load('media'));
}
/**

View file

@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEventMediaRequest;
use App\Http\Resources\EventMediaResource;
use App\Models\EventMedia;
use App\Services\EventMediaImageProcessor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
class EventMediaController extends Controller
{
public function index(Request $request, string $clientId): JsonResponse
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
return response()->json([
'data' => EventMediaResource::collection(
$event->media()->orderBy('created_at')->get()
),
]);
}
public function store(
StoreEventMediaRequest $request,
EventMediaImageProcessor $processor,
string $clientId
): JsonResponse {
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
$collection = $request->validated('collection') ?? 'gallery';
$processed = $processor->process($request->file('file'));
$uuid = (string) str()->uuid();
$directory = "event-media/{$request->user()->id}/{$event->client_id}";
$path = "{$directory}/{$uuid}.jpg";
$previewPath = "{$directory}/{$uuid}_preview.jpg";
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
Storage::disk('local')->put($path, $processed['original']);
Storage::disk('local')->put($previewPath, $processed['preview']);
Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']);
if ($collection === 'key_image') {
$this->deleteExistingKeyImages($event->media()->where('collection', 'key_image')->get());
}
$media = $event->media()->create([
'uuid' => $uuid,
'user_id' => $request->user()->id,
'collection' => $collection,
'name' => $request->file('file')->getClientOriginalName(),
'mime_type' => $processed['mime_type'],
'disk' => 'local',
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'preview_path' => $previewPath,
'size' => $processed['size'],
'width' => $processed['width'],
'height' => $processed['height'],
'thumbnail_width' => $processed['thumbnail_width'],
'thumbnail_height' => $processed['thumbnail_height'],
'preview_width' => $processed['preview_width'],
'preview_height' => $processed['preview_height'],
]);
if ($collection === 'key_image') {
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
}
return (new EventMediaResource($media))
->response()
->setStatusCode(201);
}
public function show(Request $request, EventMedia $media, string $variant): Response
{
abort_unless(in_array($variant, ['thumb', 'preview', 'original'], true), 404);
abort_unless((int) $media->user_id === (int) $request->user()->id, 404);
$path = match ($variant) {
'thumb' => $media->thumbnail_path,
'preview' => $media->preview_path ?: $media->thumbnail_path,
default => $media->path,
};
abort_unless(is_string($path) && $path !== '', 404);
abort_unless(Storage::disk($media->disk)->exists($path), 404);
return response(Storage::disk($media->disk)->get($path), 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=604800',
]);
}
public function destroy(Request $request, string $clientId, EventMedia $media): JsonResponse
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
abort_unless((int) $media->event_id === (int) $event->id, 404);
$wasKeyImage = $media->collection === 'key_image';
$this->deleteMediaFiles($media);
$media->delete();
if ($wasKeyImage) {
$event->update(['image' => null]);
}
return response()->json(null, 204);
}
private function deleteExistingKeyImages(iterable $mediaItems): void
{
foreach ($mediaItems as $media) {
$this->deleteMediaFiles($media);
$media->delete();
}
}
private function deleteMediaFiles(EventMedia $media): void
{
Storage::disk($media->disk)->delete(array_filter([
$media->path,
$media->preview_path,
$media->thumbnail_path,
]));
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateSettingsRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->json([
'data' => $request->user()->settings?->settings ?? null,
]);
}
public function update(UpdateSettingsRequest $request): JsonResponse
{
$settings = $request->validated('settings');
$userSettings = $request->user()->settings()->updateOrCreate(
[],
['settings' => $settings],
);
return response()->json([
'data' => $userSettings->settings,
]);
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSettingsMediaRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class SettingsMediaController extends Controller
{
private const BACKGROUND_MAX_SIDE = 1600;
private const BACKGROUND_QUALITY = 86;
public function store(StoreSettingsMediaRequest $request): JsonResponse
{
$processed = $this->processBackground($request->file('file'));
$path = $this->backgroundPath((int) $request->user()->id);
Storage::disk('local')->put($path, $processed['contents']);
return response()->json([
'data' => [
'url' => '/settings/media/background?v='.time(),
'width' => $processed['width'],
'height' => $processed['height'],
'mimeType' => 'image/jpeg',
'size' => strlen($processed['contents']),
],
], 201);
}
public function show(Request $request): Response
{
$path = $this->backgroundPath((int) $request->user()->id);
abort_unless(Storage::disk('local')->exists($path), 404);
return response(Storage::disk('local')->get($path), 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=604800',
]);
}
public function destroy(Request $request): JsonResponse
{
Storage::disk('local')->delete($this->backgroundPath((int) $request->user()->id));
return response()->json(null, 204);
}
private function backgroundPath(int $userId): string
{
return "settings-media/{$userId}/background.jpg";
}
/**
* @return array{contents: string, width: int, height: int}
*/
private function processBackground(UploadedFile $file): array
{
$source = imagecreatefromstring((string) file_get_contents($file->getRealPath()));
if (! $source) {
throw new RuntimeException('The uploaded background image could not be processed.');
}
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$scale = min(1, self::BACKGROUND_MAX_SIDE / max($sourceWidth, $sourceHeight));
$width = max(1, (int) round($sourceWidth * $scale));
$height = max(1, (int) round($sourceHeight * $scale));
$canvas = imagecreatetruecolor($width, $height);
$background = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $width, $height, $background);
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
imagedestroy($source);
ob_start();
imagejpeg($canvas, null, self::BACKGROUND_QUALITY);
$contents = (string) ob_get_clean();
imagedestroy($canvas);
return [
'contents' => $contents,
'width' => $width,
'height' => $height,
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email'],
'password' => ['required', 'string'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'collection' => ['nullable', 'in:key_image,gallery'],
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSettingsMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
];
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): 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'],
];
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventMediaResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => 'image',
'collection' => $this->collection,
'name' => $this->name,
'mimeType' => $this->mime_type,
'size' => $this->size,
'width' => $this->width,
'height' => $this->height,
'thumbnailWidth' => $this->thumbnail_width,
'thumbnailHeight' => $this->thumbnail_height,
'previewWidth' => $this->preview_width,
'previewHeight' => $this->preview_height,
'src' => "/event-media/{$this->id}/thumb",
'thumbnailUrl' => "/event-media/{$this->id}/thumb",
'previewUrl' => "/event-media/{$this->id}/preview",
'originalUrl' => "/event-media/{$this->id}/original",
'createdAt' => $this->created_at->getTimestampMs(),
];
}
}

View file

@ -18,6 +18,7 @@ class EventResource extends JsonResource
'gradientPreset' => $this->gradient_preset,
'image' => $this->image,
'note' => $this->note ?? '',
'media' => EventMediaResource::collection($this->whenLoaded('media')),
'syncStatus' => 'synced',
'createdAt' => $this->created_at->getTimestampMs(),
'updatedAt' => $this->updated_at->getTimestampMs(),

View file

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Event extends Model
{
@ -35,4 +36,9 @@ class Event extends Model
{
return $this->belongsTo(User::class);
}
public function media(): HasMany
{
return $this->hasMany(EventMedia::class);
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class EventMedia extends Model
{
/** @use HasFactory<\Database\Factories\EventMediaFactory> */
use HasFactory;
protected $fillable = [
'uuid',
'user_id',
'event_id',
'collection',
'name',
'mime_type',
'disk',
'path',
'thumbnail_path',
'preview_path',
'size',
'width',
'height',
'thumbnail_width',
'thumbnail_height',
'preview_width',
'preview_height',
];
protected function casts(): array
{
return [
'size' => 'integer',
'width' => 'integer',
'height' => 'integer',
'thumbnail_width' => 'integer',
'thumbnail_height' => 'integer',
'preview_width' => 'integer',
'preview_height' => 'integer',
];
}
protected static function booted(): void
{
static::creating(function (EventMedia $media): void {
if (! $media->uuid) {
$media->uuid = (string) Str::uuid();
}
});
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
@ -57,6 +58,11 @@ class User extends Authenticatable
return $this->hasMany(Event::class);
}
public function settings(): HasOne
{
return $this->hasOne(UserSetting::class);
}
public function initials(): string
{
return Str::of($this->name)

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSetting extends Model
{
/** @use HasFactory<\Database\Factories\UserSettingFactory> */
use HasFactory;
protected $fillable = [
'settings',
];
protected function casts(): array
{
return [
'settings' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class EventMediaImageProcessor
{
private const MAX_ORIGINAL_SIDE = 3508;
private const PREVIEW_MAX_SIDE = 900;
private const THUMBNAIL_SIZE = 320;
/**
* @return array{
* original: string,
* preview: string,
* thumbnail: string,
* width: int,
* height: int,
* preview_width: int,
* preview_height: int,
* thumbnail_width: int,
* thumbnail_height: int,
* mime_type: string,
* size: int
* }
*/
public function process(UploadedFile $file): array
{
$source = imagecreatefromstring((string) file_get_contents($file->getRealPath()));
if (! $source) {
throw new RuntimeException('The uploaded image could not be processed.');
}
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$original = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::MAX_ORIGINAL_SIDE);
$preview = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::PREVIEW_MAX_SIDE, 84);
$thumbnail = $this->resizeCover($source, $sourceWidth, $sourceHeight, self::THUMBNAIL_SIZE);
imagedestroy($source);
return [
'original' => $original['contents'],
'preview' => $preview['contents'],
'thumbnail' => $thumbnail['contents'],
'width' => $original['width'],
'height' => $original['height'],
'preview_width' => $preview['width'],
'preview_height' => $preview['height'],
'thumbnail_width' => self::THUMBNAIL_SIZE,
'thumbnail_height' => self::THUMBNAIL_SIZE,
'mime_type' => 'image/jpeg',
'size' => strlen($original['contents']),
];
}
/**
* @return array{contents: string, width: int, height: int}
*/
private function resizeContain(
\GdImage $source,
int $sourceWidth,
int $sourceHeight,
int $maxSide,
int $quality = 90
): array {
$scale = min(1, $maxSide / max($sourceWidth, $sourceHeight));
$width = max(1, (int) round($sourceWidth * $scale));
$height = max(1, (int) round($sourceHeight * $scale));
$canvas = imagecreatetruecolor($width, $height);
$this->fillCanvas($canvas, $width, $height);
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
return [
'contents' => $this->encodeJpeg($canvas, $quality),
'width' => $width,
'height' => $height,
];
}
/**
* @return array{contents: string, width: int, height: int}
*/
private function resizeCover(\GdImage $source, int $sourceWidth, int $sourceHeight, int $size): array
{
$scale = max($size / $sourceWidth, $size / $sourceHeight);
$width = (int) round($sourceWidth * $scale);
$height = (int) round($sourceHeight * $scale);
$x = (int) round(($size - $width) / 2);
$y = (int) round(($size - $height) / 2);
$canvas = imagecreatetruecolor($size, $size);
$this->fillCanvas($canvas, $size, $size);
imagecopyresampled($canvas, $source, $x, $y, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
return [
'contents' => $this->encodeJpeg($canvas, 80),
'width' => $size,
'height' => $size,
];
}
private function fillCanvas(\GdImage $canvas, int $width, int $height): void
{
$background = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $width, $height, $background);
}
private function encodeJpeg(\GdImage $image, int $quality): string
{
ob_start();
imagejpeg($image, null, $quality);
$contents = (string) ob_get_clean();
imagedestroy($image);
return $contents;
}
}

27
backend/config/cors.php Normal file
View file

@ -0,0 +1,27 @@
<?php
return [
'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'https://thats-me.app',
'https://www.thats-me.app',
'https://app.thats-me.app',
'https://app.thats-me.test',
'http://app.thats-me.test:9000',
'http://localhost:9000',
'http://127.0.0.1:9000',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

View file

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Models\Event;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EventMedia>
*/
class EventMediaFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$user = User::factory();
return [
'uuid' => (string) Str::uuid(),
'user_id' => $user,
'event_id' => Event::factory()->for($user),
'collection' => 'gallery',
'name' => fake()->word().'.jpg',
'mime_type' => 'image/jpeg',
'disk' => 'local',
'path' => 'event-media/test/original.jpg',
'thumbnail_path' => 'event-media/test/thumb.jpg',
'preview_path' => 'event-media/test/preview.jpg',
'size' => 12345,
'width' => 1200,
'height' => 800,
'thumbnail_width' => 320,
'thumbnail_height' => 320,
'preview_width' => 900,
'preview_height' => 600,
];
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserSetting>
*/
class UserSettingFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'settings' => [
'appearance' => 'system',
'accentColor' => 'base',
'language' => 'de',
'timelineZoom' => 1,
'timelineScrollLeft' => null,
'presets' => [],
'activePresetId' => null,
],
];
}
}

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->json('settings');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_settings');
}
};

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('event_media', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->string('collection')->default('gallery');
$table->string('name');
$table->string('mime_type');
$table->string('disk')->default('local');
$table->string('path');
$table->string('thumbnail_path');
$table->string('preview_path')->nullable();
$table->unsignedBigInteger('size');
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->unsignedInteger('thumbnail_width')->nullable();
$table->unsignedInteger('thumbnail_height')->nullable();
$table->unsignedInteger('preview_width')->nullable();
$table->unsignedInteger('preview_height')->nullable();
$table->timestamps();
$table->index(['user_id', 'event_id']);
$table->index(['event_id', 'collection']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('event_media');
}
};

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('event_media', function (Blueprint $table) {
if (! Schema::hasColumn('event_media', 'preview_path')) {
$table->string('preview_path')->nullable()->after('thumbnail_path');
}
if (! Schema::hasColumn('event_media', 'preview_width')) {
$table->unsignedInteger('preview_width')->nullable()->after('thumbnail_height');
}
if (! Schema::hasColumn('event_media', 'preview_height')) {
$table->unsignedInteger('preview_height')->nullable()->after('preview_width');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('event_media', function (Blueprint $table) {
$columns = array_filter([
Schema::hasColumn('event_media', 'preview_path') ? 'preview_path' : null,
Schema::hasColumn('event_media', 'preview_width') ? 'preview_width' : null,
Schema::hasColumn('event_media', 'preview_height') ? 'preview_height' : null,
]);
if ($columns !== []) {
$table->dropColumn($columns);
}
});
}
};

View file

@ -3,8 +3,9 @@
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
class DatabaseSeeder extends Seeder
{
@ -13,11 +14,37 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::query()->updateOrCreate(
['email' => 'test@example.com'],
[
'name' => 'Test User',
'email_verified_at' => now(),
'password' => 'password',
],
);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
foreach (range(1, 6) as $number) {
User::query()->updateOrCreate(
['email' => "user{$number}@thats-me.app"],
[
'name' => "User {$number}",
'email_verified_at' => now(),
'password' => 'pass',
],
);
}
$hasPersonalAccessClient = Client::query()
->where('provider', 'users')
->where('revoked', false)
->get()
->contains(fn (Client $client): bool => $client->hasGrantType('personal_access'));
if (! $hasPersonalAccessClient) {
app(ClientRepository::class)->createPersonalAccessGrantClient(
'Thats Me Quasar Personal Access Client',
'users',
);
}
}
}

View file

@ -1,11 +1,27 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\EventMediaController;
use App\Http\Controllers\Api\SettingsController;
use App\Http\Controllers\Api\SettingsMediaController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/settings', [SettingsController::class, 'show']);
Route::put('/settings', [SettingsController::class, 'update']);
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
Route::post('/settings/media/background', [SettingsMediaController::class, 'store']);
Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']);
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']);
Route::post('/events/{clientId}/media', [EventMediaController::class, 'store']);
Route::delete('/events/{clientId}/media/{media}', [EventMediaController::class, 'destroy']);
Route::apiResource('events', EventController::class)->parameters([
'events' => 'clientId',

View file

@ -0,0 +1,92 @@
<?php
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Support\Facades\Hash;
use Laravel\Passport\Client;
use Laravel\Passport\Passport;
test('api user endpoint requires a token', function () {
$this->getJson('/api/user')
->assertUnauthorized();
});
test('events endpoint requires a token', function () {
$this->getJson('/api/events')
->assertUnauthorized();
});
test('api user endpoint returns the authenticated user', function () {
$user = User::factory()->create([
'name' => 'API User',
'email' => 'api-user@example.com',
]);
Passport::actingAs($user);
$this->getJson('/api/user')
->assertOk()
->assertJsonPath('id', $user->id)
->assertJsonPath('name', 'API User')
->assertJsonPath('email', 'api-user@example.com');
});
test('can login with presentation user credentials', function () {
$this->seed(DatabaseSeeder::class);
$this->postJson('/api/login', [
'email' => 'user1@thats-me.app',
'password' => 'pass',
])
->assertOk()
->assertJsonPath('tokenType', 'Bearer')
->assertJsonPath('user.email', 'user1@thats-me.app')
->assertJsonPath('user.name', 'User 1')
->assertJsonPath('user.mode', 'remote')
->assertJsonStructure([
'token',
'tokenType',
'user' => ['id', 'name', 'email', 'avatar', 'mode'],
]);
});
test('login rejects invalid credentials', function () {
$this->seed(DatabaseSeeder::class);
$this->postJson('/api/login', [
'email' => 'user1@thats-me.app',
'password' => 'wrong-password',
])
->assertUnprocessable()
->assertJsonValidationErrors(['email']);
});
test('authenticated user can logout', function () {
$user = User::factory()->create();
Passport::actingAs($user);
$this->postJson('/api/logout')
->assertNoContent();
});
test('database seeder creates the presentation api users', function () {
$this->seed(DatabaseSeeder::class);
foreach (range(1, 6) as $number) {
$user = User::query()
->where('email', "user{$number}@thats-me.app")
->first();
expect($user)->not->toBeNull()
->and($user->name)->toBe("User {$number}")
->and(Hash::check('pass', $user->password))->toBeTrue();
}
$hasPersonalAccessClient = Client::query()
->where('provider', 'users')
->where('revoked', false)
->get()
->contains(fn (Client $client): bool => $client->hasGrantType('personal_access'));
expect($hasPersonalAccessClient)->toBeTrue();
});

View file

@ -0,0 +1,170 @@
<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
test('media endpoints require a token', function () {
$this->getJson('/api/events/event-id/media')
->assertUnauthorized();
});
test('can upload key image and receive protected urls', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$response = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'key_image',
'file' => UploadedFile::fake()->image('avatar.png', 1200, 800),
]);
$response->assertCreated()
->assertJsonPath('data.collection', 'key_image')
->assertJsonPath('data.type', 'image')
->assertJsonPath('data.thumbnailWidth', 320)
->assertJsonPath('data.thumbnailHeight', 320)
->assertJsonPath('data.previewWidth', 900);
$media = EventMedia::query()->firstOrFail();
Storage::disk('local')->assertExists($media->path);
Storage::disk('local')->assertExists($media->preview_path);
Storage::disk('local')->assertExists($media->thumbnail_path);
expect($event->fresh()->image)->toBe("/event-media/{$media->id}/thumb");
});
test('uploaded originals keep a4 quality maximum side', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'gallery',
'file' => UploadedFile::fake()->image('large.jpg', 5000, 2500),
])->assertCreated();
$media = EventMedia::query()->firstOrFail();
expect($media->width)->toBe(3508)
->and($media->height)->toBe(1754)
->and($media->preview_width)->toBe(900)
->and($media->preview_height)->toBe(450)
->and(max($media->width, $media->height))->toBe(3508);
});
test('can list media for own event', function () {
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'collection' => 'gallery',
]);
Passport::actingAs($user);
$this->getJson("/api/events/{$event->client_id}/media")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.collection', 'gallery');
});
test('can stream thumbnail for own media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/thumb")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('can stream preview for own media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'preview_path' => 'event-media/test/preview.jpg',
]);
Storage::disk('local')->put($media->preview_path, 'fake-jpeg');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/preview")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('preview falls back to thumbnail for legacy media', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'preview_path' => null,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'legacy-thumb');
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/preview")
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('cannot access another users media', function () {
Storage::fake('local');
$user = User::factory()->create();
$otherUser = User::factory()->create();
$event = Event::factory()->create(['user_id' => $otherUser->id]);
$media = EventMedia::factory()->create([
'user_id' => $otherUser->id,
'event_id' => $event->id,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
Passport::actingAs($user);
$this->getJson("/api/events/{$event->client_id}/media")
->assertNotFound();
$this->get("/api/event-media/{$media->id}/thumb")
->assertNotFound();
});
test('can delete own media and files', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'path' => 'event-media/test/original.jpg',
'preview_path' => null,
'thumbnail_path' => 'event-media/test/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
Passport::actingAs($user);
$this->deleteJson("/api/events/{$event->client_id}/media/{$media->id}")
->assertNoContent();
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
});

View file

@ -0,0 +1,148 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
test('settings endpoint requires a token', function () {
$this->getJson('/api/settings')
->assertUnauthorized();
});
test('can get empty settings for authenticated user', function () {
Passport::actingAs(User::factory()->create());
$this->getJson('/api/settings')
->assertOk()
->assertJsonPath('data', null);
});
test('can store and update settings', function () {
$user = User::factory()->create();
Passport::actingAs($user);
$settings = [
'appearance' => 'dark',
'accentColor' => 'green',
'language' => 'de',
'timelineZoom' => 1.5,
'timelineScrollLeft' => 420,
'floatingLines' => [
'speed' => 1.2,
'lineCount' => 12,
],
'presets' => [[
'id' => 'preset-1',
'name' => 'Praesentation',
'settings' => ['accentColor' => 'green'],
'updatedAt' => 1710000000000,
]],
'activePresetId' => 'preset-1',
'showFps' => false,
];
$this->putJson('/api/settings', ['settings' => $settings])
->assertOk()
->assertJsonPath('data.appearance', 'dark')
->assertJsonPath('data.floatingLines.speed', 1.2)
->assertJsonPath('data.presets.0.name', 'Praesentation');
expect($user->settings()->count())->toBe(1);
$this->putJson('/api/settings', [
'settings' => [
...$settings,
'accentColor' => 'blue',
],
])
->assertOk()
->assertJsonPath('data.accentColor', 'blue');
expect($user->settings()->count())->toBe(1);
});
test('settings are isolated per user', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
UserSetting::factory()->create([
'user_id' => $otherUser->id,
'settings' => [
'accentColor' => 'rose',
'language' => 'en',
],
]);
Passport::actingAs($user);
$this->getJson('/api/settings')
->assertOk()
->assertJsonPath('data', null);
$this->putJson('/api/settings', [
'settings' => [
'accentColor' => 'green',
'language' => 'de',
],
])->assertOk();
expect($user->settings()->first()->settings['accentColor'])->toBe('green')
->and($otherUser->settings()->first()->settings['accentColor'])->toBe('rose');
});
test('settings payload is required', function () {
Passport::actingAs(User::factory()->create());
$this->putJson('/api/settings', [])
->assertUnprocessable()
->assertJsonValidationErrors(['settings']);
});
test('can upload and stream own settings background image', function () {
Storage::fake('local');
$user = User::factory()->create();
Passport::actingAs($user);
$response = $this->postJson('/api/settings/media/background', [
'file' => UploadedFile::fake()->image('background.png', 2400, 1200),
]);
$response
->assertCreated()
->assertJsonPath('data.width', 1600)
->assertJsonPath('data.height', 800)
->assertJsonPath('data.mimeType', 'image/jpeg');
expect($response->json('data.url'))->toStartWith('/settings/media/background?v=');
Storage::disk('local')->assertExists("settings-media/{$user->id}/background.jpg");
$this->get('/api/settings/media/background')
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
});
test('settings background images are isolated per user', function () {
Storage::fake('local');
$user = User::factory()->create();
$otherUser = User::factory()->create();
Storage::disk('local')->put("settings-media/{$otherUser->id}/background.jpg", 'other-background');
Passport::actingAs($user);
$this->get('/api/settings/media/background')
->assertNotFound();
});
test('can delete own settings background image', function () {
Storage::fake('local');
$user = User::factory()->create();
Storage::disk('local')->put("settings-media/{$user->id}/background.jpg", 'background');
Passport::actingAs($user);
$this->deleteJson('/api/settings/media/background')
->assertNoContent();
Storage::disk('local')->assertMissing("settings-media/{$user->id}/background.jpg");
});