Skip to content

Commit

Permalink
feat: use presigned urls for s3 (#666)
Browse files Browse the repository at this point in the history
* feat: use presigned urls for s3

* feat: upload avatar using presigned url

* feat: upload avatar using presigned url

* feat: upload avatar using presigned url

* feat: upload avatar using presigned url

* fix: pr comments

* fix: pr comments and remove unused userId from presignedUrl api call

* style: fix linting

* fix: regenerate ts types
  • Loading branch information
simonostendorf authored Sep 7, 2024
1 parent c7b4619 commit 1f99444
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 50 deletions.
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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,
]);
}
}
46 changes: 9 additions & 37 deletions app/Http/Controllers/DashboardAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -134,7 +121,7 @@ public function editUser(IlluminateRequest $request): RedirectResponse
$user->syncRoles($roles);
}

Session::flash('success', 'Der Account <strong>'.$user->email.'</strong> wurde erfolgreich bearbeitet. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.');
Session::flash('success', 'Der Account <strong>' . $user->email . '</strong> wurde erfolgreich bearbeitet. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.');

return Redirect::back();
}
Expand Down Expand Up @@ -171,7 +158,7 @@ public function deleteUser(IlluminateRequest $request): RedirectResponse
Storage::disk('s3')->delete($userTemp->avatar);
}

Session::flash('success', 'Der Account <strong>'.$userTemp->email.'</strong> wurde erfolgreich gelöscht. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.');
Session::flash('success', 'Der Account <strong>' . $userTemp->email . '</strong> wurde erfolgreich gelöscht. Die Tabelle aktualisiert sich in wenigen Sekunden automatisch.');

return Redirect::back();
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 <strong>'.$user->email.'</strong> wurde erfolgreich erstellt.');
Session::flash('success', 'Der Account <strong>' . $user->email . '</strong> wurde erfolgreich erstellt.');

return Redirect::back();
}
Expand Down Expand Up @@ -469,7 +441,7 @@ public function assignUser(): RedirectResponse
'queue_position' => $queuePosition,
]);

Session::flash('success', 'Der Account <strong>'.$user->email.'</strong> wurde erfolgreich für das Event <strong>'.$event->name.'</strong>'.(array_key_exists('slot_id', $userRegistration) ? ' zu dem Slot <strong>'.$slot->name.'</strong>' : '').' zugewiesen.');
Session::flash('success', 'Der Account <strong>' . $user->email . '</strong> wurde erfolgreich für das Event <strong>' . $event->name . '</strong>' . (array_key_exists('slot_id', $userRegistration) ? ' zu dem Slot <strong>' . $slot->name . '</strong>' : '') . ' zugewiesen.');

return Redirect::back();
}
Expand Down
12 changes: 7 additions & 5 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 43 additions & 1 deletion resources/js/components/user/EditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefinded>();
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");
};
</script>
16 changes: 16 additions & 0 deletions resources/js/composables/useS3.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
45 changes: 44 additions & 1 deletion resources/js/pages/Dashboard/Admin/Register.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -213,7 +216,47 @@ const selectFormSlotOptions = computed(() => {
const randomPlaceholderPerson = usePlaceholderPerson();
const registerSubmitHandler = async () => {
Inertia.post("/dashboard/admin/register", registerForm.value);
const avatarPath = ref<string | undefinded>();
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 () => {
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
1 change: 0 additions & 1 deletion resources/js/types/group.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
});
Expand Down

0 comments on commit 1f99444

Please sign in to comment.