diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 33b96f8a..c85adc06 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -10,6 +10,8 @@ use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class ApiController extends Controller { @@ -320,4 +322,27 @@ public function users(): JsonResponse 'users' => $users, ]); } + + /** + * Generate a presigned URL for avatar upload + */ + public function generatePresignedUrlForAvatarUpload(Request $request): JsonResponse + { + $request->validate([ + 'avatar' => 'required|image', + ]); + + $uuid = Str::uuid()->toString(); + $fileName = $uuid . '.' . $request->avatar->extension(); + $path = 'avatars/' . $fileName; + $presignedUrl = Storage::disk('s3')->temporaryUploadUrl( + $path, + now()->addMinutes(5) + ); + + return response()->json([ + 'presignedUrl' => $presignedUrl, + 'path' => $path, + ]); + } } diff --git a/app/Http/Controllers/DashboardAdminController.php b/app/Http/Controllers/DashboardAdminController.php index 4cce2bce..6c1cca5f 100644 --- a/app/Http/Controllers/DashboardAdminController.php +++ b/app/Http/Controllers/DashboardAdminController.php @@ -16,7 +16,6 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; use Spatie\Permission\Models\Role; @@ -70,13 +69,13 @@ public function editUser(IlluminateRequest $request): RedirectResponse $validated = Request::validate([ 'firstname' => ['required', 'string', 'min:2', 'max:255'], 'lastname' => ['required', 'string', 'min:2', 'max:255'], - 'email' => ['required', 'string', 'email', 'min:3', 'max:255', 'unique:users,email,'.$user->id], + 'email' => ['required', 'string', 'email', 'min:3', 'max:255', 'unique:users,email,' . $user->id], 'email_confirm' => ['required', 'string', 'email', 'min:3', 'max:255', 'same:email'], 'course_id' => ['required', 'integer', 'exists:courses,id'], 'role_id' => ['array'], 'is_disabled' => ['boolean'], 'remove_avatar' => ['boolean'], - 'avatar.*.file' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif'], + 'avatar' => ['nullable', 'string'], ]); // check if all roles exists and not super admin if so add to roles array @@ -107,18 +106,6 @@ public function editUser(IlluminateRequest $request): RedirectResponse // set avatar to null $validated['avatar'] = null; - } elseif (array_key_exists('avatar', $validated) && $validated['avatar'][0]) { - // get avatar file - $avatarFile = Request::file('avatar')[0]['file']; - - // generate a uuid - $uuid = Str::uuid()->toString(); - - // store file in s3 bucket - $path = Storage::disk('s3')->put('/avatars/'.$uuid, $avatarFile); - - // add avatar to validated array - $validated['avatar'] = $path; } // remove email_confirm, role_id and remove_avatar from array @@ -134,7 +121,7 @@ public function editUser(IlluminateRequest $request): RedirectResponse $user->syncRoles($roles); } - Session::flash('success', 'Der Account '.$user->email.' wurde erfolgreich bearbeitet. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.'); + Session::flash('success', 'Der Account ' . $user->email . ' wurde erfolgreich bearbeitet. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.'); return Redirect::back(); } @@ -171,7 +158,7 @@ public function deleteUser(IlluminateRequest $request): RedirectResponse Storage::disk('s3')->delete($userTemp->avatar); } - Session::flash('success', 'Der Account '.$userTemp->email.' wurde erfolgreich gelöscht. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.'); + Session::flash('success', 'Der Account ' . $userTemp->email . ' wurde erfolgreich gelöscht. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.'); return Redirect::back(); } @@ -277,8 +264,8 @@ public function eventExecuteSubmit(IlluminateRequest $request): RedirectResponse if (count($groups) > 0) { // get max_groups and max_participants for course by request - $maxGroups = $request->input('max_groups_'.$course->id); - $maxParticipants = $request->input('max_participants_'.$course->id); + $maxGroups = $request->input('max_groups_' . $course->id); + $maxParticipants = $request->input('max_participants_' . $course->id); $groupCourseDivision = new GroupCourseDivision($event, $course, $event->consider_alcohol, (int) $maxGroups, (int) $maxParticipants); $groupCourseDivision->assign(); @@ -352,31 +339,16 @@ public function registerUser(): RedirectResponse 'email' => ['required', 'string', 'email', 'min:3', 'max:255', 'unique:users'], 'email_confirm' => ['required', 'string', 'email', 'min:3', 'max:255', 'same:email'], 'course_id' => ['required', 'integer', 'exists:courses,id'], - 'avatar.*.file' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif'], + 'avatar' => ['nullable', 'string'], ]); // remove email_confirm from array unset($validated['email_confirm']); - // check if avatar is set - if (array_key_exists('avatar', $validated) && $validated['avatar'][0]) { - // get avatar file - $avatarFile = Request::file('avatar')[0]['file']; - - // generate a uuid - $uuid = Str::uuid()->toString(); - - // store file in s3 bucket - $path = Storage::disk('s3')->put('/avatars/'.$uuid, $avatarFile); - - // add avatar to validated array - $validated['avatar'] = $path; - } - // create the user $user = User::create($validated); - Session::flash('success', 'Der Account '.$user->email.' wurde erfolgreich erstellt.'); + Session::flash('success', 'Der Account ' . $user->email . ' wurde erfolgreich erstellt.'); return Redirect::back(); } @@ -469,7 +441,7 @@ public function assignUser(): RedirectResponse 'queue_position' => $queuePosition, ]); - Session::flash('success', 'Der Account '.$user->email.' wurde erfolgreich für das Event '.$event->name.''.(array_key_exists('slot_id', $userRegistration) ? ' zu dem Slot '.$slot->name.'' : '').' zugewiesen.'); + Session::flash('success', 'Der Account ' . $user->email . ' wurde erfolgreich für das Event ' . $event->name . '' . (array_key_exists('slot_id', $userRegistration) ? ' zu dem Slot ' . $slot->name . '' : '') . ' zugewiesen.'); return Redirect::back(); } diff --git a/app/Models/User.php b/app/Models/User.php index 4595f0a0..b8e27ac8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -62,11 +62,13 @@ public function course(): BelongsTo */ public function avatarUrl(): ?string { - // check if avatar is set - if ($this->avatar) { - - // get immage from S3 - return Storage::disk('s3')->url($this->avatar); + // check if avatar is set and file exists + if ($this->avatar && Storage::disk('s3')->exists($this->avatar)) { + // create presigned url + return Storage::disk('s3')->temporaryUrl( + $this->avatar, + now()->addMinutes(60) + ); } return null; diff --git a/resources/js/components/user/EditModal.vue b/resources/js/components/user/EditModal.vue index f4286202..b1a4219b 100644 --- a/resources/js/components/user/EditModal.vue +++ b/resources/js/components/user/EditModal.vue @@ -197,11 +197,53 @@ const selectFormCourseOptions = useSelectFormCourseOptions(courses, true); const selectFormRoleOptions = useSelectFormRoleOptions(roles); const randomPlaceholderPerson = usePlaceholderPerson(); +const { uploadFileByPresignedUrl } = useS3(); + const close = () => { emits("close"); }; const editSubmitHandler = async () => { - Inertia.post(`/dashboard/admin/user/${user.id}`, editForm.value); + const avatarPath = ref(); + + if (editForm.value.avatar?.length) { + const formData = new FormData(); + formData.append("avatar", editForm.value.avatar[0].file); + + const response = await fetch(`/api/user/presigned-avatar-url`, { + method: "POST", + credentials: "include", + headers: { + "X-CSRF-TOKEN": + document + .querySelector("meta[name='csrf-token']") + ?.getAttribute("content") || "", + }, + body: formData, + }); + + if (!response.ok) { + console.error("Failed to get presigned URL for avatar upload"); + return; + } + + const data = await response.json(); + + try { + await uploadFileByPresignedUrl( + formData.get("avatar"), + data.presignedUrl.url, + ); + avatarPath.value = data.path; + } catch (error) { + console.error("Failed to upload avatar", error); + return; + } + } + + Inertia.post(`/dashboard/admin/user/${user.id}`, { + ...editForm.value, + avatar: avatarPath.value, + }); emits("submit"); }; diff --git a/resources/js/composables/useS3.ts b/resources/js/composables/useS3.ts new file mode 100644 index 00000000..62f42ed3 --- /dev/null +++ b/resources/js/composables/useS3.ts @@ -0,0 +1,16 @@ +export default function () { + const uploadFileByPresignedUrl = async (file: File, presignedUrl: string) => { + const response = await fetch(presignedUrl, { + method: "PUT", + body: file, + }); + + if (!response.ok) { + throw new Error("Failed to upload file"); + } + }; + + return { + uploadFileByPresignedUrl, + }; +} diff --git a/resources/js/pages/Dashboard/Admin/Register.vue b/resources/js/pages/Dashboard/Admin/Register.vue index 1293577f..bbac39c3 100644 --- a/resources/js/pages/Dashboard/Admin/Register.vue +++ b/resources/js/pages/Dashboard/Admin/Register.vue @@ -201,6 +201,9 @@ const getEventById = (id: number) => { const selectFormCourseOptions = useSelectFormCourseOptions(courses, true); const selectFormEventOptions = useSelectFormEventOptions(events); + +const { uploadFileByPresignedUrl } = useS3(); + const selectFormSlotOptions = computed(() => { const event = getEventById(assignForm.value.event_id); @@ -213,7 +216,47 @@ const selectFormSlotOptions = computed(() => { const randomPlaceholderPerson = usePlaceholderPerson(); const registerSubmitHandler = async () => { - Inertia.post("/dashboard/admin/register", registerForm.value); + const avatarPath = ref(); + + if (registerForm.value.avatar?.length) { + const formData = new FormData(); + formData.append("avatar", registerForm.value.avatar[0].file); + + const response = await fetch(`/api/user/presigned-avatar-url`, { + method: "POST", + credentials: "include", + headers: { + "X-CSRF-TOKEN": + document + .querySelector("meta[name='csrf-token']") + ?.getAttribute("content") || "", + }, + body: formData, + }); + + if (!response.ok) { + console.error("Failed to get presigned URL for avatar upload"); + return; + } + + const data = await response.json(); + + try { + await uploadFileByPresignedUrl( + formData.get("avatar"), + data.presignedUrl.url, + ); + avatarPath.value = data.path; + } catch (error) { + console.error("Failed to upload avatar", error); + return; + } + } + + Inertia.post("/dashboard/admin/register", { + ...registerForm.value, + avatar: avatarPath.value, + }); }; const assignSubmitHandler = async () => { diff --git a/resources/js/types/auto-imports.d.ts b/resources/js/types/auto-imports.d.ts index c61fc46e..32da3350 100644 --- a/resources/js/types/auto-imports.d.ts +++ b/resources/js/types/auto-imports.d.ts @@ -9,6 +9,7 @@ declare global { const useColorMode: (typeof import("../composables/useColorMode"))["default"]; const usePagesAsNavigation: (typeof import("../composables/usePagesAsNavigation"))["default"]; const usePlaceholderPerson: (typeof import("../composables/usePlaceholderPerson"))["default"]; + const useS3: (typeof import("../composables/useS3"))["default"]; const useSelectFormCourseOptions: (typeof import("../composables/useSelectFormCourseOptions"))["default"]; const useSelectFormEventOptions: (typeof import("../composables/useSelectFormEventOptions"))["default"]; const useSelectFormRoleOptions: (typeof import("../composables/useSelectFormRoleOptions"))["default"]; diff --git a/resources/js/types/group.d.ts b/resources/js/types/group.d.ts index 92bfb2e2..29fbb3f1 100644 --- a/resources/js/types/group.d.ts +++ b/resources/js/types/group.d.ts @@ -4,7 +4,6 @@ declare namespace App.Models { created_at: string /* Date */ | null; updated_at: string /* Date */ | null; event_id: number; - course_id: number | null; name: string; group_tutors?: GroupTutor[] | null; registrations?: Registration[] | null; diff --git a/routes/web.php b/routes/web.php index 6151987a..0b914278 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,7 +32,7 @@ Route::get('/login', [AppController::class, 'login'])->name('app.login'); Route::post('/login', [AppController::class, 'loginUser'])->name('app.loginUser'); - Route::middleware(ActiveModule::class.':registration')->group(function () { + Route::middleware(ActiveModule::class . ':registration')->group(function () { Route::get('/register', [AppController::class, 'register'])->name('app.register'); Route::post('/register', [AppController::class, 'registerUser'])->name('app.registerUser'); }); @@ -84,13 +84,13 @@ Route::post('/event/{event}/submit', [DashboardAdminController::class, 'eventExecuteSubmit'])->name('dashboard.admin.event.executeSubmit'); }); - Route::middleware(ActiveModule::class.':randomGenerator', 'can:manage random generator')->group(function () { + Route::middleware(ActiveModule::class . ':randomGenerator', 'can:manage random generator')->group(function () { Route::get('/random-generator', [DashboardAdminRandomGeneratorController::class, 'index'])->name('dashboard.admin.randomGenerator.index'); Route::post('/random-generator', [DashboardAdminRandomGeneratorController::class, 'indexExecuteSubmit'])->name('dashboard.admin.randomGenerator.indexExecuteSubmit'); Route::get('/random-generator/display', [DashboardAdminRandomGeneratorController::class, 'display'])->name('dashboard.admin.randomGenerator.display'); }); - Route::middleware(ActiveModule::class.':scoreSystem', 'can:manage score system')->group(function () { + Route::middleware(ActiveModule::class . ':scoreSystem', 'can:manage score system')->group(function () { Route::get('/score-system', [DashboardAdminScoreSystemController::class, 'index'])->name('dashboard.admin.scoreSystem.index'); Route::post('/score-system', [DashboardAdminScoreSystemController::class, 'indexExecuteSubmit'])->name('dashboard.admin.scoreSystem.indexExecuteSubmit'); Route::get('/score-system/display', [DashboardAdminScoreSystemController::class, 'display'])->name('dashboard.admin.scoreSystem.display'); @@ -123,16 +123,17 @@ Route::middleware('can:manage users')->group(function () { Route::get('/users', [ApiController::class, 'users'])->name('api.users'); + Route::post('/user/presigned-avatar-url', [ApiController::class, 'generatePresignedUrlForAvatarUpload'])->name('api.user.presignedAvatarUrl'); }); }); Route::get('/registrations/{registration}', [ApiController::class, 'registrationsShow'])->name('api.registrations.show'); - Route::middleware(ActiveModule::class.':randomGenerator', 'can:manage random generator')->group(function () { + Route::middleware(ActiveModule::class . ':randomGenerator', 'can:manage random generator')->group(function () { Route::get('/random-generator/state', [ApiController::class, 'randomGeneratorState'])->name('api.randomGeneratorState'); }); - Route::middleware(ActiveModule::class.':scoreSystem', 'can:manage score system')->group(function () { + Route::middleware(ActiveModule::class . ':scoreSystem', 'can:manage score system')->group(function () { Route::get('/score-system/state', [ApiController::class, 'scoreSystemState'])->name('api.scoreSystemState'); }); });