Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use presigned urls for s3 #666

Merged
merged 10 commits into from
Sep 7, 2024
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after regenerating the types, the course_id was removed from the groups model. Currently I dont know why this happened.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is available as course?: Course | 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