APP als Hybrid Version - Anbindung an API
This commit is contained in:
parent
d054732bf5
commit
c1514999be
46 changed files with 3418 additions and 196 deletions
54
backend/app/Http/Controllers/Api/AuthController.php
Normal file
54
backend/app/Http/Controllers/Api/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
138
backend/app/Http/Controllers/Api/EventMediaController.php
Normal file
138
backend/app/Http/Controllers/Api/EventMediaController.php
Normal 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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
32
backend/app/Http/Controllers/Api/SettingsController.php
Normal file
32
backend/app/Http/Controllers/Api/SettingsController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
97
backend/app/Http/Controllers/Api/SettingsMediaController.php
Normal file
97
backend/app/Http/Controllers/Api/SettingsMediaController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Requests/LoginRequest.php
Normal file
26
backend/app/Http/Requests/LoginRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Requests/StoreEventMediaRequest.php
Normal file
26
backend/app/Http/Requests/StoreEventMediaRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
backend/app/Http/Requests/StoreSettingsMediaRequest.php
Normal file
23
backend/app/Http/Requests/StoreSettingsMediaRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
backend/app/Http/Requests/UpdateSettingsRequest.php
Normal file
37
backend/app/Http/Requests/UpdateSettingsRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
backend/app/Http/Resources/EventMediaResource.php
Normal file
33
backend/app/Http/Resources/EventMediaResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
backend/app/Models/EventMedia.php
Normal file
66
backend/app/Models/EventMedia.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
29
backend/app/Models/UserSetting.php
Normal file
29
backend/app/Models/UserSetting.php
Normal 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);
|
||||
}
|
||||
}
|
||||
125
backend/app/Services/EventMediaImageProcessor.php
Normal file
125
backend/app/Services/EventMediaImageProcessor.php
Normal 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
27
backend/config/cors.php
Normal 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,
|
||||
];
|
||||
44
backend/database/factories/EventMediaFactory.php
Normal file
44
backend/database/factories/EventMediaFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
backend/database/factories/UserSettingFactory.php
Normal file
33
backend/database/factories/UserSettingFactory.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
92
backend/tests/Feature/Api/AuthTest.php
Normal file
92
backend/tests/Feature/Api/AuthTest.php
Normal 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();
|
||||
});
|
||||
170
backend/tests/Feature/Api/EventMediaTest.php
Normal file
170
backend/tests/Feature/Api/EventMediaTest.php
Normal 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);
|
||||
});
|
||||
148
backend/tests/Feature/Api/SettingsTest.php
Normal file
148
backend/tests/Feature/Api/SettingsTest.php
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue