4586079fae
CI / test (push) Has been cancelled
The first rotation pass picked CW server-side / CCW preview-side based on "ribbon on left" → user rotates frame 90° CCW. On hardware the photo came out upside down, which means the user's physical rotation is the opposite of what was assumed: 90° CW from landscape native, putting the ribbon to the left from the user's POV but to the right from the EPD's reference frame. The two rotation signs always need to stay opposite — flipping both keeps the webapp preview upright while fixing the device. Also drops the temporary upload debug log; the cropOrientation persistence issue resolved on its own once Doctrine's metadata cache was cleared. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Entity\Device;
|
||
use App\Entity\Image;
|
||
use App\Entity\RenderedAsset;
|
||
use App\Enum\DeviceModel;
|
||
use App\Enum\Orientation;
|
||
use App\Enum\RenderStatus;
|
||
use App\Enum\TokenType;
|
||
use App\Message\RenderImageMessage;
|
||
use App\Service\ShareService;
|
||
use App\Service\TokenService;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\Messenger\MessageBusInterface;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||
|
||
#[Route('/api/images')]
|
||
#[IsGranted('ROLE_USER')]
|
||
class ImageApiController extends AbstractController
|
||
{
|
||
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||
private const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
|
||
|
||
public function __construct(
|
||
#[Autowire('%kernel.project_dir%')]
|
||
private readonly string $projectDir,
|
||
) {}
|
||
|
||
#[Route('', name: 'api_images_list', methods: ['GET'])]
|
||
public function list(EntityManagerInterface $em): JsonResponse
|
||
{
|
||
$user = $this->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,
|
||
): 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'))
|
||
);
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|