From 97ecbebb9eeb69673a9f6ae89d01af6efba64121 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:19:28 +0300 Subject: [PATCH] add icon uploads --- backend/app/Controllers/ElementController.php | 92 ++++++++++++++++++- .../UpdateElement/UpdateElementIconImage.php | 81 ++++++++++++++++ .../UpdateElementIconImageRequest.php | 15 +++ backend/app/Providers/AppServiceProvider.php | 6 ++ backend/app/Shared/Files/FileToUpload.php | 34 +++++++ backend/app/Shared/Files/FileUploader.php | 10 ++ .../app/Shared/Files/LaravelFileUploader.php | 44 +++++++++ backend/routes/api.php | 5 + 8 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php create mode 100644 backend/app/Shared/Files/FileToUpload.php create mode 100644 backend/app/Shared/Files/FileUploader.php create mode 100644 backend/app/Shared/Files/LaravelFileUploader.php diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 6a63a85..66edf8c 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -5,18 +5,24 @@ namespace App\Controllers; use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; +use App\Element\UseCases\UpdateElement\UpdateElementIconImage; +use App\Element\UseCases\UpdateElement\UpdateElementIconImageRequest; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Shared\Files\FileUploader; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; class ElementController { public function __construct( private GetElement $getElement, private UpdateElement $updateElement, + private UpdateElementIconImage $updateElementIconImage, + private FileUploader $fileUploader, ) { } @@ -84,6 +90,35 @@ class ElementController ], 200); } + public function uploadIconImage(?int $id, Request $request): JsonResponse + { + $iconImage = $this->uploadedFileInput($request, 'iconImage'); + + try { + $element = $this->updateElementIconImage->execute( + new UpdateElementIconImageRequest( + id: $id, + iconImageContents: $iconImage['contents'], + iconImageOriginalName: $iconImage['originalName'], + iconImageMimeType: $iconImage['mimeType'], + iconImageSizeBytes: $iconImage['sizeBytes'], + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 200); + } + private function stringInput(Request $request, string $key): ?string { $value = $request->input($key); @@ -94,6 +129,45 @@ class ElementController return $value; } + /** + * @return array{ + * contents: string|null, + * originalName: string|null, + * mimeType: string|null, + * sizeBytes: int|null + * } + */ + private function uploadedFileInput(Request $request, string $key): array + { + $emptyFileInput = [ + 'contents' => null, + 'originalName' => null, + 'mimeType' => null, + 'sizeBytes' => null, + ]; + + if (! $request->hasFile($key)) { + return $emptyFileInput; + } + + $file = $request->file($key); + if (! $file instanceof UploadedFile) { + return $emptyFileInput; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + return $emptyFileInput; + } + + return [ + 'contents' => (string) file_get_contents($realPath), + 'originalName' => $file->getClientOriginalName(), + 'mimeType' => (string) $file->getMimeType(), + 'sizeBytes' => (int) $file->getSize(), + ]; + } + /** * @return array{ * id: int, @@ -111,10 +185,26 @@ class ElementController 'id' => $element->getId(), 'title' => $element->getTitle(), 'description' => $element->getDescription(), - 'iconImageUrl' => $element->getIconImageUrl(), + 'iconImageUrl' => $this->storageUrl( + $element->getIconImageUrl(), + 'element-icons/', + ), 'richText' => $element->getRichText(), 'pdfPath' => $element->getPdfPath(), 'youtubeUrl' => $element->getYoutubeUrl(), ]; } + + private function storageUrl(?string $path, string $folderPrefix): ?string + { + if ($path === null) { + return null; + } + + if (str_starts_with($path, $folderPrefix)) { + return $this->fileUploader->url($path); + } + + return $path; + } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php new file mode 100644 index 0000000..4cdd746 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php @@ -0,0 +1,81 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ( + $request->iconImageContents === null + || $request->iconImageOriginalName === null + || $request->iconImageMimeType === null + || $request->iconImageSizeBytes === null + ) { + throw new BadRequestException('icon image is required'); + } + + if ( + ! in_array( + $request->iconImageMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException( + 'icon image must be a jpeg, png or webp image', + ); + } + + if ($request->iconImageSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException( + 'icon image must be 5MB or smaller', + ); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $iconImage = new FileToUpload( + contents: $request->iconImageContents, + originalName: $request->iconImageOriginalName, + mimeType: $request->iconImageMimeType, + sizeBytes: $request->iconImageSizeBytes, + ); + $path = $this->fileUploader->upload($iconImage, 'element-icons'); + $element->setIconImageUrl($path); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php new file mode 100644 index 0000000..d230d96 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php @@ -0,0 +1,15 @@ +app->bind( + FileUploader::class, + LaravelFileUploader::class, + ); } } diff --git a/backend/app/Shared/Files/FileToUpload.php b/backend/app/Shared/Files/FileToUpload.php new file mode 100644 index 0000000..e8b8f41 --- /dev/null +++ b/backend/app/Shared/Files/FileToUpload.php @@ -0,0 +1,34 @@ +contents; + } + + public function getOriginalName(): string + { + return $this->originalName; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getSizeBytes(): int + { + return $this->sizeBytes; + } +} diff --git a/backend/app/Shared/Files/FileUploader.php b/backend/app/Shared/Files/FileUploader.php new file mode 100644 index 0000000..b5bad4e --- /dev/null +++ b/backend/app/Shared/Files/FileUploader.php @@ -0,0 +1,10 @@ +extensionFor($file); + Storage::disk('public')->put($path, $file->getContents()); + + return $path; + } + + public function url(string $path): string + { + return Storage::disk('public')->url($path); + } + + private function extensionFor(FileToUpload $file): string + { + $mimeExtensions = [ + 'image/jpeg' => '.jpg', + 'image/png' => '.png', + 'image/webp' => '.webp', + ]; + if (isset($mimeExtensions[$file->getMimeType()])) { + return $mimeExtensions[$file->getMimeType()]; + } + + $originalExtension = pathinfo( + $file->getOriginalName(), + PATHINFO_EXTENSION, + ); + if ($originalExtension !== '') { + return '.' . $originalExtension; + } + + return ''; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 9d253d0..b864ae2 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -14,3 +14,8 @@ Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); Route::patch('/elements/{id}', [ElementController::class, 'update']) ->middleware(AuthMiddleware::class); +Route::post( + '/elements/{id}/icon-image', + [ElementController::class, 'uploadIconImage'], +) + ->middleware(AuthMiddleware::class);