Web app: new entities (Image, RenderedAsset, SharedImage, Token, DeviceImageHistory), enums, repositories, controllers, message handlers, migrations, tests, frontend upload/library/sticker UI, Vue components. Firmware: EPD background screen binaries + gen scripts, setup_bg header. Infra: ddev config, test bundle, gitignore coverage dir. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
<?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'));
|
||||
}
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user