getUser(); $images = $em->getRepository(Image::class)->findBy( ['user' => $user, 'deletedAt' => null], ['uploadedAt' => 'DESC'], ); return $this->json(array_map($this->serialize(...), $images)); } #[Route('', name: 'api_images_upload', methods: ['POST'])] public function upload( Request $request, EntityManagerInterface $em, MessageBusInterface $bus, LoggerInterface $logger, ): JsonResponse { $file = $request->files->get('file'); if (!$file) { return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY); } if ($file->getSize() > self::MAX_BYTES) { return $this->json(['error' => 'File too large (max 30 MB)'], Response::HTTP_UNPROCESSABLE_ENTITY); } if (!in_array($file->getMimeType(), self::ALLOWED_MIME, true)) { return $this->json(['error' => 'Unsupported file type'], Response::HTTP_UNPROCESSABLE_ENTITY); } /** @var \App\Entity\User $user */ $user = $this->getUser(); // Create Image entity first with a placeholder path so we have the ID $image = (new Image()) ->setUser($user) ->setOriginalFilename($file->getClientOriginalName()); $em->persist($image); $em->flush(); // get ID // Build storage directory $storageDir = $this->projectDir . '/var/storage/images/' . $image->getId(); if (!is_dir($storageDir)) { mkdir($storageDir, 0755, true); } // If a separate pre-crop original was sent, save it; otherwise the uploaded file IS the original $originalFile = $request->files->get('original') ?? $file; $ext = strtolower($originalFile->guessExtension() ?? 'jpg'); $origRelPath = 'var/storage/images/' . $image->getId() . '/original.' . $ext; $originalFile->move($storageDir, 'original.' . $ext); // If a composited (cropped+stickered) version was also sent, save it separately if ($request->files->get('original')) { // $file is the composited; move to composited.jpg for the renderer $file->move($storageDir, 'composited.jpg'); } $image->setStoragePath($origRelPath); if ($request->request->has('cropParams')) { $image->setCropParams($request->request->get('cropParams')); } if ($request->request->has('stickerState')) { $image->setStickerState($request->request->get('stickerState')); } if ($request->request->has('cropOrientation')) { $image->setCropOrientation( Orientation::tryFrom((string) $request->request->get('cropOrientation')) ); } // TEMP debug: log what arrived in the upload form so we can diagnose // why crop_orientation is landing as NULL despite the frontend // claiming to send it. Remove once stable. $logger->info('image.upload.fields', [ 'image_id' => $image->getId(), 'has_cropParams' => $request->request->has('cropParams'), 'has_stickerState' => $request->request->has('stickerState'), 'has_cropOrient' => $request->request->has('cropOrientation'), 'cropOrient_raw' => $request->request->get('cropOrientation'), 'all_keys' => $request->request->keys(), 'persisted_orient' => $image->getCropOrientation()?->value, ]); // Generate thumbnail from composited if available, otherwise from original $thumbSrc = file_exists($storageDir . '/composited.jpg') ? $storageDir . '/composited.jpg' : $storageDir . '/original.' . $ext; $this->generateThumbnail($thumbSrc, $storageDir . '/thumbnail.jpg'); $em->flush(); // Dispatch rendering for all model × orientation combos foreach (DeviceModel::cases() as $model) { foreach (Orientation::cases() as $orientation) { $asset = (new RenderedAsset()) ->setImage($image) ->setDeviceModel($model) ->setOrientation($orientation); $em->persist($asset); $bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value)); } } $em->flush(); return $this->json($this->serialize($image), Response::HTTP_CREATED); } #[Route('/{id}', name: 'api_images_delete', methods: ['DELETE'])] public function delete(int $id, EntityManagerInterface $em): JsonResponse { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $image->setDeletedAt(new \DateTimeImmutable()); $em->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } #[Route('/{id}/thumbnail', name: 'api_images_thumbnail', methods: ['GET'])] public function thumbnail(int $id, EntityManagerInterface $em): Response { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $thumbPath = $this->projectDir . '/var/storage/images/' . $id . '/thumbnail.jpg'; if (!file_exists($thumbPath)) { return $this->json(['error' => 'Thumbnail not ready'], Response::HTTP_NOT_FOUND); } return new BinaryFileResponse($thumbPath); } #[Route('/{id}/original', name: 'api_images_original', methods: ['GET'])] public function original(int $id, EntityManagerInterface $em): Response { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $storageDir = $this->projectDir . '/var/storage/images/' . $id; foreach (['original.jpg', 'original.png', 'original.webp', 'original.gif'] as $candidate) { $path = $storageDir . '/' . $candidate; if (file_exists($path)) { return new BinaryFileResponse($path); } } return $this->json(['error' => 'Original not found'], Response::HTTP_NOT_FOUND); } #[Route('/{id}/reprocess', name: 'api_images_reprocess', methods: ['POST'])] public function reprocess( int $id, Request $request, EntityManagerInterface $em, MessageBusInterface $bus, ): JsonResponse { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $file = $request->files->get('file'); if (!$file) { return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY); } $storageDir = $this->projectDir . '/var/storage/images/' . $id; // Overwrite composited with the new version $file->move($storageDir, 'composited.jpg'); // Regenerate thumbnail from new composited $this->generateThumbnail($storageDir . '/composited.jpg', $storageDir . '/thumbnail.jpg'); // Persist updated crop/sticker metadata if provided if ($request->request->has('cropParams')) { $image->setCropParams($request->request->get('cropParams')); } if ($request->request->has('stickerState')) { $image->setStickerState($request->request->get('stickerState')); } if ($request->request->has('cropOrientation')) { $image->setCropOrientation( Orientation::tryFrom((string) $request->request->get('cropOrientation')) ); } // Reset all rendered assets so they re-render from the new composited foreach ($image->getRenderedAssets() as $asset) { $asset->setStatus(RenderStatus::Pending)->setFilePath(null); $bus->dispatch(new RenderImageMessage($id, $asset->getDeviceModel()->value, $asset->getOrientation()->value)); } $em->flush(); return $this->json($this->serialize($image)); } #[Route('/{id}/share', name: 'api_images_share', methods: ['POST'])] public function share(int $id, Request $request, EntityManagerInterface $em, ShareService $shareService): JsonResponse { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $body = json_decode($request->getContent(), true) ?? []; $email = trim((string) ($body['recipientEmail'] ?? '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return $this->json(['error' => 'Invalid email address'], Response::HTTP_UNPROCESSABLE_ENTITY); } try { /** @var \App\Entity\User $user */ $user = $this->getUser(); $shareService->share($image, $user, $email); } catch (\InvalidArgumentException $e) { return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); } return $this->json(null, Response::HTTP_NO_CONTENT); } #[Route('/{id}/hard-delete-request', name: 'api_images_hard_delete_request', methods: ['POST'])] public function hardDeleteRequest(int $id, EntityManagerInterface $em, TokenService $tokenService): JsonResponse { $image = $this->findOwnedImage($id, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } /** @var \App\Entity\User $user */ $user = $this->getUser(); $ttl = (int) ($_ENV['HARD_DELETE_TOKEN_TTL_DAYS'] ?? 30); $tokenService->issue(TokenType::HardDeleteConfirm, $image, $user, $user->getEmail(), $ttl); $em->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } #[Route('/{id}/approve/{deviceId}', name: 'api_images_approve', methods: ['POST'])] public function approve(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse { return $this->toggleApproval($id, $deviceId, $em, true); } #[Route('/{id}/approve/{deviceId}', name: 'api_images_revoke', methods: ['DELETE'])] public function revoke(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse { return $this->toggleApproval($id, $deviceId, $em, false); } private function toggleApproval(int $imageId, int $deviceId, EntityManagerInterface $em, bool $approve): JsonResponse { /** @var \App\Entity\User $user */ $user = $this->getUser(); $image = $this->findOwnedImage($imageId, $em); if (!$image) { return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND); } $device = $em->getRepository(Device::class)->findOneBy(['id' => $deviceId, 'user' => $user]); if (!$device) { return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND); } if ($approve) { $image->approveForDevice($device); } else { $image->revokeForDevice($device); } $em->flush(); return $this->json($this->serialize($image)); } private function findOwnedImage(int $id, EntityManagerInterface $em): ?Image { $image = $em->getRepository(Image::class)->findOneBy([ 'id' => $id, 'user' => $this->getUser(), 'deletedAt' => null, ]); return $image; } private function serialize(Image $image): array { $id = $image->getId(); return [ 'id' => $id, 'originalFilename' => $image->getOriginalFilename(), 'thumbnailUrl' => '/api/images/' . $id . '/thumbnail', 'originalUrl' => '/api/images/' . $id . '/original', 'uploadedAt' => $image->getUploadedAt()->format(\DateTimeInterface::ATOM), 'approvedDeviceIds' => array_values($image->getApprovedDevices()->map(fn($d) => $d->getId())->toArray()), 'cropParams' => $image->getCropParams() ? json_decode($image->getCropParams(), true) : null, 'stickerState' => $image->getStickerState() ? json_decode($image->getStickerState(), true) : null, 'cropOrientation' => $image->getCropOrientation()?->value, ]; } private function generateThumbnail(string $srcPath, string $destPath): void { $imagick = new \Imagick($srcPath); $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $imagick->setBackgroundColor('white'); $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); $imagick->autoOrient(); $imagick->thumbnailImage(800, 600, true); $imagick->setImageFormat('jpeg'); $imagick->setImageCompressionQuality(80); $imagick->writeImage($destPath); $imagick->destroy(); } }