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,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\SharedImage;
|
||||
use App\Entity\User;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\SharedImageStatus;
|
||||
use App\Message\RenderImageMessage;
|
||||
use App\Repository\SharedImageRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
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/shared-images')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class SharedImageApiController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SharedImageRepository $sharedImageRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MessageBusInterface $bus,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_shared_images_list', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$status = $request->query->get('status');
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$statusEnum = $status ? SharedImageStatus::tryFrom($status) : null;
|
||||
|
||||
$result = $this->sharedImageRepository->findForUser($user, $statusEnum, $page, $limit);
|
||||
|
||||
return $this->json([
|
||||
'items' => array_map($this->serialize(...), $result['items']),
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'totalPages' => (int) ceil($result['total'] / $limit),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/pending-count', name: 'api_shared_images_pending_count', methods: ['GET'])]
|
||||
public function pendingCount(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
return $this->json(['count' => $this->sharedImageRepository->pendingCountForUser($user)]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/approve', name: 'api_shared_images_approve', methods: ['POST'])]
|
||||
public function approve(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$shared = $this->findOwnedShared($id);
|
||||
if (!$shared) {
|
||||
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$deviceIds = json_decode($request->getContent(), true)['deviceIds'] ?? [];
|
||||
|
||||
foreach ($deviceIds as $deviceId) {
|
||||
$device = $this->em->getRepository(Device::class)->findOneBy(['id' => (int) $deviceId, 'user' => $user]);
|
||||
if (!$device) {
|
||||
continue;
|
||||
}
|
||||
$shared->getSourceImage()->approveForDevice($device);
|
||||
}
|
||||
|
||||
$shared->setStatus(SharedImageStatus::Approved);
|
||||
$this->em->flush();
|
||||
|
||||
// Dispatch renders for any missing rendered assets
|
||||
$image = $shared->getSourceImage();
|
||||
foreach (DeviceModel::cases() as $model) {
|
||||
foreach (Orientation::cases() as $orientation) {
|
||||
$this->bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->json($this->serialize($shared));
|
||||
}
|
||||
|
||||
#[Route('/{id}/decline', name: 'api_shared_images_decline', methods: ['POST'])]
|
||||
public function decline(int $id): JsonResponse
|
||||
{
|
||||
$shared = $this->findOwnedShared($id);
|
||||
if (!$shared) {
|
||||
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Revoke approvals if previously approved
|
||||
if ($shared->getStatus() === SharedImageStatus::Approved) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$devices = $this->em->getRepository(Device::class)->findBy(['user' => $user]);
|
||||
foreach ($devices as $device) {
|
||||
$shared->getSourceImage()->revokeForDevice($device);
|
||||
}
|
||||
}
|
||||
|
||||
$shared->setStatus(SharedImageStatus::Declined);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json($this->serialize($shared));
|
||||
}
|
||||
|
||||
private function findOwnedShared(int $id): ?SharedImage
|
||||
{
|
||||
return $this->em->getRepository(SharedImage::class)->findOneBy([
|
||||
'id' => $id,
|
||||
'recipientUser' => $this->getUser(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function serialize(SharedImage $s): array
|
||||
{
|
||||
return [
|
||||
'id' => $s->getId(),
|
||||
'imageId' => $s->getSourceImage()->getId(),
|
||||
'thumbnailUrl' => '/api/images/' . $s->getSourceImage()->getId() . '/thumbnail',
|
||||
'sharedBy' => $s->getSharedBy()->getEmail(),
|
||||
'sharedAt' => $s->getSharedAt()->format(\DateTimeInterface::ATOM),
|
||||
'status' => $s->getStatus()->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Orientation;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -58,8 +59,21 @@ class DeviceApiController extends AbstractController
|
||||
$device->setOrientation($orientation);
|
||||
}
|
||||
|
||||
if (isset($body['rotationIntervalHours'])) {
|
||||
$device->setRotationIntervalHours(max(1, (int) $body['rotationIntervalHours']));
|
||||
if (isset($body['rotationIntervalMinutes'])) {
|
||||
$device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes']));
|
||||
}
|
||||
|
||||
if (array_key_exists('wakeHour', $body)) {
|
||||
$device->setWakeHour($body['wakeHour'] === null ? null : (int) $body['wakeHour']);
|
||||
}
|
||||
|
||||
if (isset($body['timezone'])) {
|
||||
try {
|
||||
new \DateTimeZone((string) $body['timezone']);
|
||||
$device->setTimezone((string) $body['timezone']);
|
||||
} catch (\Exception) {
|
||||
return $this->json(['error' => 'Invalid timezone identifier'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($body['uniquenessWindow'])) {
|
||||
@@ -71,16 +85,70 @@ class DeviceApiController extends AbstractController
|
||||
return $this->json($this->serialize($device));
|
||||
}
|
||||
|
||||
#[Route('/{id}/lock', name: 'api_device_lock', methods: ['PUT'])]
|
||||
public function lock(int $id, Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
|
||||
|
||||
if (!$device) {
|
||||
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$imageId = $body['imageId'] ?? null;
|
||||
|
||||
if (!$imageId) {
|
||||
return $this->json(['error' => 'imageId required'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$image = $em->getRepository(Image::class)->find($imageId);
|
||||
if (!$image || $image->getUser() !== $user) {
|
||||
return $this->json(['error' => 'Image not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!$image->isApprovedForDevice($device)) {
|
||||
return $this->json(['error' => 'Image is not approved for this device'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$device->setLockedImage($image);
|
||||
$em->flush();
|
||||
|
||||
return $this->json($this->serialize($device));
|
||||
}
|
||||
|
||||
#[Route('/{id}/lock', name: 'api_device_unlock', methods: ['DELETE'])]
|
||||
public function unlock(int $id, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
|
||||
|
||||
if (!$device) {
|
||||
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$device->setLockedImage(null);
|
||||
$em->flush();
|
||||
|
||||
return $this->json($this->serialize($device));
|
||||
}
|
||||
|
||||
private function serialize(Device $d): array
|
||||
{
|
||||
return [
|
||||
'id' => $d->getId(),
|
||||
'mac' => $d->getMac(),
|
||||
'name' => $d->getName(),
|
||||
'orientation' => $d->getOrientation()->value,
|
||||
'rotationIntervalHours' => $d->getRotationIntervalHours(),
|
||||
'uniquenessWindow' => $d->getUniquenessWindow(),
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
'id' => $d->getId(),
|
||||
'mac' => $d->getMac(),
|
||||
'name' => $d->getName(),
|
||||
'orientation' => $d->getOrientation()->value,
|
||||
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
|
||||
'wakeHour' => $d->getWakeHour(),
|
||||
'timezone' => $d->getTimezone(),
|
||||
'uniquenessWindow' => $d->getUniquenessWindow(),
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
|
||||
'lockedImageId' => $d->getLockedImage()?->getId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Service\RotationService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class DeviceImageController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
private readonly RotationService $rotation,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
private function computeIntervalMs(Device $device): int
|
||||
{
|
||||
if ($device->getWakeHour() !== null) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
$next = $now->setTime($device->getWakeHour(), 0, 0);
|
||||
if ($next->getTimestamp() <= $now->getTimestamp()) {
|
||||
$next = $next->modify('+1 day');
|
||||
}
|
||||
return (int) (($next->getTimestamp() - $now->getTimestamp()) * 1000);
|
||||
}
|
||||
|
||||
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
||||
}
|
||||
|
||||
#[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
|
||||
public function image(string $mac, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$device = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
|
||||
if (!$device) {
|
||||
$this->logger->warning('device.poll.unknown_mac', ['mac' => $mac]);
|
||||
return new Response(null, Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$intervalMs = $this->computeIntervalMs($device);
|
||||
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
||||
$device->markSeen();
|
||||
|
||||
// Locked image bypasses rotation entirely.
|
||||
$image = $device->getLockedImage() ?? $this->rotation->advance($device);
|
||||
|
||||
if ($image === null) {
|
||||
$this->logger->info('device.poll.no_image', [
|
||||
'device_id' => $device->getId(),
|
||||
'mac' => $mac,
|
||||
'interval_ms' => $intervalMs,
|
||||
]);
|
||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||
return $r;
|
||||
}
|
||||
|
||||
// 304: device already has this image — skip the binary transfer and redraw.
|
||||
if ($image->getId() === $currentImageId) {
|
||||
$this->logger->info('device.poll.no_change', [
|
||||
'device_id' => $device->getId(),
|
||||
'mac' => $mac,
|
||||
'image_id' => $image->getId(),
|
||||
'interval_ms' => $intervalMs,
|
||||
]);
|
||||
$r = new Response(null, Response::HTTP_NOT_MODIFIED);
|
||||
$r->headers->set('X-Image-Id', (string) $image->getId());
|
||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||
return $r;
|
||||
}
|
||||
|
||||
$asset = $em->getRepository(RenderedAsset::class)->findOneBy([
|
||||
'image' => $image,
|
||||
'deviceModel' => $device->getModel(),
|
||||
'orientation' => $device->getOrientation(),
|
||||
'status' => RenderStatus::Ready,
|
||||
]);
|
||||
|
||||
if (!$asset?->getFilePath()) {
|
||||
$this->logger->warning('device.poll.no_asset', [
|
||||
'device_id' => $device->getId(),
|
||||
'mac' => $mac,
|
||||
'image_id' => $image->getId(),
|
||||
'model' => $device->getModel()->value,
|
||||
'orientation' => $device->getOrientation()->value,
|
||||
]);
|
||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||
return $r;
|
||||
}
|
||||
|
||||
$binPath = $this->projectDir . '/' . $asset->getFilePath();
|
||||
if (!file_exists($binPath)) {
|
||||
$this->logger->error('device.poll.file_missing', [
|
||||
'device_id' => $device->getId(),
|
||||
'mac' => $mac,
|
||||
'image_id' => $image->getId(),
|
||||
'path' => $binPath,
|
||||
]);
|
||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||
return $r;
|
||||
}
|
||||
|
||||
$this->logger->info('device.poll.served', [
|
||||
'device_id' => $device->getId(),
|
||||
'mac' => $mac,
|
||||
'image_id' => $image->getId(),
|
||||
'interval_ms' => $intervalMs,
|
||||
'bytes' => filesize($binPath),
|
||||
]);
|
||||
|
||||
$response = new BinaryFileResponse($binPath);
|
||||
$response->headers->set('Content-Type', 'application/octet-stream');
|
||||
$response->headers->set('X-Image-Id', (string) $image->getId());
|
||||
$response->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class SetupController extends AbstractController
|
||||
if ($request->isMethod('POST')) {
|
||||
$name = trim((string) $request->request->get('name', ''));
|
||||
$orient = $request->request->get('orientation', Orientation::Landscape->value);
|
||||
$interval = (int) $request->request->get('rotation_interval_hours', 24);
|
||||
$interval = (int) $request->request->get('rotation_interval_minutes', 1440);
|
||||
$window = (int) $request->request->get('uniqueness_window', 10);
|
||||
|
||||
if (empty($name)) {
|
||||
@@ -137,7 +137,7 @@ class SetupController extends AbstractController
|
||||
|
||||
$device->setName($name);
|
||||
$device->setOrientation(Orientation::from($orient));
|
||||
$device->setRotationIntervalHours(max(1, $interval));
|
||||
$device->setRotationIntervalMinutes(max(1, $interval));
|
||||
$device->setUniquenessWindow(max(1, $window));
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -38,10 +38,11 @@ class SpaController extends AbstractController
|
||||
$theme = $user->getTheme() ?? 'warm-craft';
|
||||
|
||||
$userData = json_encode([
|
||||
'id' => $user->getId(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'theme' => $theme,
|
||||
'id' => $user->getId(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'theme' => $theme,
|
||||
'timezone' => $user->getTimezone(),
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
|
||||
|
||||
$html = (string) file_get_contents($indexFile);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Token;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\SharedImage;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\SharedImageStatus;
|
||||
use App\Enum\TokenType;
|
||||
use App\Message\RenderImageMessage;
|
||||
use App\Service\TokenService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class TokenActionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MessageBusInterface $bus,
|
||||
) {}
|
||||
|
||||
#[Route('/token/{uuid}/approve', name: 'token_approve_show', methods: ['GET'])]
|
||||
public function approveShow(string $uuid): Response
|
||||
{
|
||||
$token = $this->tokenService->findValid($uuid, TokenType::ShareApprove);
|
||||
if (!$token) {
|
||||
return $this->render('token/invalid.html.twig', ['reason' => 'This approval link has expired or already been used.']);
|
||||
}
|
||||
|
||||
$user = $this->getUser();
|
||||
$devices = $user ? $this->em->getRepository(Device::class)->findBy(['user' => $user]) : [];
|
||||
|
||||
return $this->render('token/approve.html.twig', [
|
||||
'token' => $token,
|
||||
'devices' => $devices,
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/token/{uuid}/approve', name: 'token_approve_submit', methods: ['POST'])]
|
||||
public function approveSubmit(string $uuid, Request $request): Response
|
||||
{
|
||||
$token = $this->tokenService->findValid($uuid, TokenType::ShareApprove);
|
||||
if (!$token) {
|
||||
return $this->render('token/invalid.html.twig', ['reason' => 'This approval link has expired or already been used.']);
|
||||
}
|
||||
|
||||
$user = $this->getUser();
|
||||
if (!$user) {
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
$deviceIds = $request->request->all('device_ids');
|
||||
$image = $token->getImage();
|
||||
|
||||
foreach ($deviceIds as $deviceId) {
|
||||
$device = $this->em->getRepository(Device::class)->findOneBy(['id' => (int) $deviceId, 'user' => $user]);
|
||||
if (!$device) {
|
||||
continue;
|
||||
}
|
||||
$image->approveForDevice($device);
|
||||
}
|
||||
|
||||
foreach (DeviceModel::cases() as $model) {
|
||||
foreach (Orientation::cases() as $orientation) {
|
||||
$this->bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
|
||||
}
|
||||
}
|
||||
|
||||
$shared = $this->em->getRepository(SharedImage::class)->findOneBy([
|
||||
'sourceImage' => $image,
|
||||
'recipientUser' => $user,
|
||||
]);
|
||||
if ($shared) {
|
||||
$shared->setStatus(SharedImageStatus::Approved);
|
||||
}
|
||||
|
||||
$this->tokenService->consume($uuid, TokenType::ShareApprove);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->render('token/approved.html.twig', ['image' => $image]);
|
||||
}
|
||||
|
||||
#[Route('/token/{uuid}/decline', name: 'token_decline_show', methods: ['GET'])]
|
||||
public function declineShow(string $uuid, Request $request): Response
|
||||
{
|
||||
$token = $this->tokenService->findValid($uuid, TokenType::ShareDecline);
|
||||
if (!$token) {
|
||||
return $this->render('token/invalid.html.twig', ['reason' => 'This decline link has expired or already been used.']);
|
||||
}
|
||||
|
||||
return $this->render('token/decline.html.twig', [
|
||||
'token' => $token,
|
||||
'approveUuid' => $request->query->get('back'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/token/{uuid}/decline', name: 'token_decline_submit', methods: ['POST'])]
|
||||
public function declineSubmit(string $uuid): Response
|
||||
{
|
||||
$token = $this->tokenService->consume($uuid, TokenType::ShareDecline);
|
||||
|
||||
$user = $this->getUser();
|
||||
if ($user) {
|
||||
$shared = $this->em->getRepository(SharedImage::class)->findOneBy([
|
||||
'sourceImage' => $token->getImage(),
|
||||
'recipientUser' => $user,
|
||||
]);
|
||||
if ($shared) {
|
||||
$shared->setStatus(SharedImageStatus::Declined);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('token/declined.html.twig');
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,34 @@ class UserApiController extends AbstractController
|
||||
'honey-slate',
|
||||
];
|
||||
|
||||
#[Route('/search', name: 'api_users_search', methods: ['GET'])]
|
||||
public function search(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$q = trim((string) $request->query->get('q', ''));
|
||||
if (strlen($q) < 2) {
|
||||
return $this->json([]);
|
||||
}
|
||||
|
||||
/** @var User $me */
|
||||
$me = $this->getUser();
|
||||
|
||||
$results = $em->createQueryBuilder()
|
||||
->select('u')
|
||||
->from(User::class, 'u')
|
||||
->where('u.email LIKE :q')
|
||||
->andWhere('u.id != :me')
|
||||
->setParameter('q', '%' . $q . '%')
|
||||
->setParameter('me', $me->getId())
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $this->json(array_map(
|
||||
static fn(User $u) => ['id' => $u->getId(), 'email' => $u->getEmail()],
|
||||
$results,
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/theme', name: 'api_user_theme', methods: ['PATCH'])]
|
||||
public function updateTheme(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
@@ -43,4 +71,28 @@ class UserApiController extends AbstractController
|
||||
|
||||
return $this->json(['theme' => $theme]);
|
||||
}
|
||||
|
||||
#[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])]
|
||||
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$tz = $body['timezone'] ?? null;
|
||||
|
||||
if (!is_string($tz)) {
|
||||
return $this->json(['error' => 'Missing timezone'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
try {
|
||||
new \DateTimeZone($tz);
|
||||
} catch (\Exception) {
|
||||
return $this->json(['error' => 'Invalid timezone identifier'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$user->setTimezone($tz);
|
||||
$em->flush();
|
||||
|
||||
return $this->json(['timezone' => $tz]);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-4
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Entity\Image;
|
||||
use App\Enum\Orientation;
|
||||
use App\Repository\DeviceRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -22,12 +24,23 @@ class Device
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(enumType: DeviceModel::class)]
|
||||
private DeviceModel $model = DeviceModel::V1;
|
||||
|
||||
#[ORM\Column(enumType: Orientation::class)]
|
||||
private Orientation $orientation = Orientation::Landscape;
|
||||
|
||||
/** Hours between rotation cycles. */
|
||||
/** Minutes between rotation cycles (used when wakeHour is null). */
|
||||
#[ORM\Column]
|
||||
private int $rotationIntervalHours = 24;
|
||||
private int $rotationIntervalMinutes = 1440;
|
||||
|
||||
/** Hour of day (0-23, local time) at which the device should wake; null = use rotationIntervalMinutes. */
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $wakeHour = null;
|
||||
|
||||
/** IANA timezone for wakeHour scheduling (e.g. 'Europe/Stockholm'). */
|
||||
#[ORM\Column(length: 60)]
|
||||
private string $timezone = 'UTC';
|
||||
|
||||
/** Number of display cycles before an image may repeat. */
|
||||
#[ORM\Column]
|
||||
@@ -40,6 +53,18 @@ class Device
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $linkedAt;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Image $currentImage = null;
|
||||
|
||||
/** When set, rotation is bypassed and this image is always served. */
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Image $lockedImage = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $lastSeenAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->linkedAt = new \DateTimeImmutable();
|
||||
@@ -53,11 +78,20 @@ class Device
|
||||
public function getName(): string { return $this->name; }
|
||||
public function setName(string $name): static { $this->name = $name; return $this; }
|
||||
|
||||
public function getModel(): DeviceModel { return $this->model; }
|
||||
public function setModel(DeviceModel $model): static { $this->model = $model; return $this; }
|
||||
|
||||
public function getOrientation(): Orientation { return $this->orientation; }
|
||||
public function setOrientation(Orientation $orientation): static { $this->orientation = $orientation; return $this; }
|
||||
|
||||
public function getRotationIntervalHours(): int { return $this->rotationIntervalHours; }
|
||||
public function setRotationIntervalHours(int $h): static { $this->rotationIntervalHours = $h; return $this; }
|
||||
public function getRotationIntervalMinutes(): int { return $this->rotationIntervalMinutes; }
|
||||
public function setRotationIntervalMinutes(int $m): static { $this->rotationIntervalMinutes = $m; return $this; }
|
||||
|
||||
public function getWakeHour(): ?int { return $this->wakeHour; }
|
||||
public function setWakeHour(?int $hour): static { $this->wakeHour = ($hour !== null) ? max(0, min(23, $hour)) : null; return $this; }
|
||||
|
||||
public function getTimezone(): string { return $this->timezone; }
|
||||
public function setTimezone(string $tz): static { $this->timezone = $tz; return $this; }
|
||||
|
||||
public function getUniquenessWindow(): int { return $this->uniquenessWindow; }
|
||||
public function setUniquenessWindow(int $w): static { $this->uniquenessWindow = $w; return $this; }
|
||||
@@ -67,4 +101,13 @@ class Device
|
||||
|
||||
public function getLinkedAt(): \DateTimeImmutable { return $this->linkedAt; }
|
||||
public function setLinkedAt(\DateTimeImmutable $dt): static { $this->linkedAt = $dt; return $this; }
|
||||
|
||||
public function getCurrentImage(): ?Image { return $this->currentImage; }
|
||||
public function setCurrentImage(?Image $image): static { $this->currentImage = $image; return $this; }
|
||||
|
||||
public function getLockedImage(): ?Image { return $this->lockedImage; }
|
||||
public function setLockedImage(?Image $image): static { $this->lockedImage = $image; return $this; }
|
||||
|
||||
public function getLastSeenAt(): ?\DateTimeImmutable { return $this->lastSeenAt; }
|
||||
public function markSeen(): static { $this->lastSeenAt = new \DateTimeImmutable(); return $this; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\DeviceImageHistoryRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DeviceImageHistoryRepository::class)]
|
||||
#[ORM\Index(columns: ['device_id', 'served_at'], name: 'idx_history_device_served')]
|
||||
class DeviceImageHistory
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Device $device;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Image $image;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $servedAt;
|
||||
|
||||
public function __construct(Device $device, Image $image)
|
||||
{
|
||||
$this->device = $device;
|
||||
$this->image = $image;
|
||||
$this->servedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getDevice(): Device { return $this->device; }
|
||||
public function getImage(): Image { return $this->image; }
|
||||
public function getServedAt(): \DateTimeImmutable { return $this->servedAt; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ImageRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ImageRepository::class)]
|
||||
class Image
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'images')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $originalFilename = '';
|
||||
|
||||
/** Relative path from project root to the original file. */
|
||||
#[ORM\Column(length: 500)]
|
||||
private string $storagePath = '';
|
||||
|
||||
/** JSON-encoded crop parameters from the client editor, or null if not cropped. */
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $cropParams = null;
|
||||
|
||||
/** JSON-encoded sticker state from the client editor, or null if no stickers. */
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $stickerState = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $uploadedAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null;
|
||||
|
||||
/** @var Collection<int, Device> */
|
||||
#[ORM\ManyToMany(targetEntity: Device::class)]
|
||||
#[ORM\JoinTable(name: 'image_device_approval')]
|
||||
private Collection $approvedDevices;
|
||||
|
||||
/** @var Collection<int, RenderedAsset> */
|
||||
#[ORM\OneToMany(targetEntity: RenderedAsset::class, mappedBy: 'image', orphanRemoval: true)]
|
||||
private Collection $renderedAssets;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uploadedAt = new \DateTimeImmutable();
|
||||
$this->approvedDevices = new ArrayCollection();
|
||||
$this->renderedAssets = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function setUser(?User $user): static { $this->user = $user; return $this; }
|
||||
|
||||
public function getOriginalFilename(): string { return $this->originalFilename; }
|
||||
public function setOriginalFilename(string $f): static { $this->originalFilename = $f; return $this; }
|
||||
|
||||
public function getStoragePath(): string { return $this->storagePath; }
|
||||
public function setStoragePath(string $p): static { $this->storagePath = $p; return $this; }
|
||||
|
||||
public function getUploadedAt(): \DateTimeImmutable { return $this->uploadedAt; }
|
||||
|
||||
public function getDeletedAt(): ?\DateTimeImmutable { return $this->deletedAt; }
|
||||
public function setDeletedAt(?\DateTimeImmutable $dt): static { $this->deletedAt = $dt; return $this; }
|
||||
|
||||
public function isDeleted(): bool { return $this->deletedAt !== null; }
|
||||
|
||||
public function getCropParams(): ?string { return $this->cropParams; }
|
||||
public function setCropParams(?string $p): static { $this->cropParams = $p; return $this; }
|
||||
|
||||
public function getStickerState(): ?string { return $this->stickerState; }
|
||||
public function setStickerState(?string $s): static { $this->stickerState = $s; return $this; }
|
||||
|
||||
/** @return Collection<int, Device> */
|
||||
public function getApprovedDevices(): Collection { return $this->approvedDevices; }
|
||||
|
||||
public function approveForDevice(Device $device): void
|
||||
{
|
||||
if (!$this->approvedDevices->contains($device)) {
|
||||
$this->approvedDevices->add($device);
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeForDevice(Device $device): void
|
||||
{
|
||||
$this->approvedDevices->removeElement($device);
|
||||
}
|
||||
|
||||
public function isApprovedForDevice(Device $device): bool
|
||||
{
|
||||
return $this->approvedDevices->contains($device);
|
||||
}
|
||||
|
||||
/** @return Collection<int, RenderedAsset> */
|
||||
public function getRenderedAssets(): Collection { return $this->renderedAssets; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Repository\RenderedAssetRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: RenderedAssetRepository::class)]
|
||||
#[ORM\UniqueConstraint(fields: ['image', 'deviceModel', 'orientation'])]
|
||||
class RenderedAsset
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'renderedAssets')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Image $image = null;
|
||||
|
||||
#[ORM\Column(enumType: DeviceModel::class)]
|
||||
private DeviceModel $deviceModel;
|
||||
|
||||
#[ORM\Column(enumType: Orientation::class)]
|
||||
private Orientation $orientation;
|
||||
|
||||
#[ORM\Column(enumType: RenderStatus::class)]
|
||||
private RenderStatus $status = RenderStatus::Pending;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $filePath = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $renderedAt = null;
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
|
||||
public function getImage(): ?Image { return $this->image; }
|
||||
public function setImage(?Image $image): static { $this->image = $image; return $this; }
|
||||
|
||||
public function getDeviceModel(): DeviceModel { return $this->deviceModel; }
|
||||
public function setDeviceModel(DeviceModel $m): static { $this->deviceModel = $m; return $this; }
|
||||
|
||||
public function getOrientation(): Orientation { return $this->orientation; }
|
||||
public function setOrientation(Orientation $o): static { $this->orientation = $o; return $this; }
|
||||
|
||||
public function getStatus(): RenderStatus { return $this->status; }
|
||||
public function setStatus(RenderStatus $s): static { $this->status = $s; return $this; }
|
||||
|
||||
public function getFilePath(): ?string { return $this->filePath; }
|
||||
public function setFilePath(?string $p): static { $this->filePath = $p; return $this; }
|
||||
|
||||
public function getRenderedAt(): ?\DateTimeImmutable { return $this->renderedAt; }
|
||||
public function setRenderedAt(?\DateTimeImmutable $dt): static { $this->renderedAt = $dt; return $this; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\SharedImageStatus;
|
||||
use App\Repository\SharedImageRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SharedImageRepository::class)]
|
||||
#[ORM\Index(columns: ['recipient_user_id', 'status'], name: 'idx_shared_recipient_status')]
|
||||
class SharedImage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Image $sourceImage;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $recipientUser;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $sharedBy;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $sharedAt;
|
||||
|
||||
#[ORM\Column(enumType: SharedImageStatus::class)]
|
||||
private SharedImageStatus $status = SharedImageStatus::Pending;
|
||||
|
||||
public function __construct(Image $sourceImage, User $recipientUser, User $sharedBy)
|
||||
{
|
||||
$this->sourceImage = $sourceImage;
|
||||
$this->recipientUser = $recipientUser;
|
||||
$this->sharedBy = $sharedBy;
|
||||
$this->sharedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getSourceImage(): Image { return $this->sourceImage; }
|
||||
public function getRecipientUser(): User { return $this->recipientUser; }
|
||||
public function getSharedBy(): User { return $this->sharedBy; }
|
||||
public function getSharedAt(): \DateTimeImmutable { return $this->sharedAt; }
|
||||
public function getStatus(): SharedImageStatus { return $this->status; }
|
||||
public function setStatus(SharedImageStatus $status): static { $this->status = $status; return $this; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\TokenType;
|
||||
use App\Repository\TokenRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TokenRepository::class)]
|
||||
class Token
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(length: 36)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(enumType: TokenType::class)]
|
||||
private TokenType $type;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Image $image;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $recipientUser;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
private ?string $recipientEmail;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $expiresAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $usedAt = null;
|
||||
|
||||
public function __construct(
|
||||
TokenType $type,
|
||||
Image $image,
|
||||
?User $recipientUser,
|
||||
?string $recipientEmail,
|
||||
int $ttlDays,
|
||||
) {
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->type = $type;
|
||||
$this->image = $image;
|
||||
$this->recipientUser = $recipientUser;
|
||||
$this->recipientEmail = $recipientEmail;
|
||||
$this->expiresAt = new \DateTimeImmutable('+' . $ttlDays . ' days');
|
||||
}
|
||||
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getType(): TokenType { return $this->type; }
|
||||
public function getImage(): Image { return $this->image; }
|
||||
public function getRecipientUser(): ?User { return $this->recipientUser; }
|
||||
public function getRecipientEmail(): ?string { return $this->recipientEmail; }
|
||||
public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
|
||||
public function getUsedAt(): ?\DateTimeImmutable { return $this->usedAt; }
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->usedAt === null && $this->expiresAt > new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function consume(): void
|
||||
{
|
||||
$this->usedAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Entity\Image;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
@@ -35,10 +36,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $theme = null;
|
||||
|
||||
#[ORM\Column(length: 60, nullable: true)]
|
||||
private ?string $timezone = null;
|
||||
|
||||
/** @var Collection<int, Device> */
|
||||
#[ORM\OneToMany(targetEntity: Device::class, mappedBy: 'user', orphanRemoval: true)]
|
||||
private Collection $devices;
|
||||
|
||||
/** @var Collection<int, Image> */
|
||||
#[ORM\OneToMany(targetEntity: Image::class, mappedBy: 'user', orphanRemoval: true)]
|
||||
private Collection $images;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -97,13 +105,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimezone(): string
|
||||
{
|
||||
return $this->timezone ?? 'UTC';
|
||||
}
|
||||
|
||||
public function setTimezone(?string $timezone): static
|
||||
{
|
||||
$this->timezone = $timezone;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
/** @return Collection<int, Device> */
|
||||
public function getDevices(): Collection { return $this->devices; }
|
||||
|
||||
/** @return Collection<int, Image> */
|
||||
public function getImages(): Collection { return $this->images; }
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->devices = new ArrayCollection();
|
||||
$this->images = new ArrayCollection();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum DeviceModel: string
|
||||
{
|
||||
case V1 = 'v1'; // Waveshare 7.3" 800×480
|
||||
|
||||
public function width(Orientation $orientation): int
|
||||
{
|
||||
return $orientation === Orientation::Portrait ? 480 : 800;
|
||||
}
|
||||
|
||||
public function height(Orientation $orientation): int
|
||||
{
|
||||
return $orientation === Orientation::Portrait ? 800 : 480;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum RenderStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Processing = 'processing';
|
||||
case Ready = 'ready';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum SharedImageStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Declined = 'declined';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum TokenType: string
|
||||
{
|
||||
case ShareApprove = 'share_approve';
|
||||
case ShareDecline = 'share_decline';
|
||||
case HardDeleteConfirm = 'hard_delete_confirm';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class AdvanceRotationMessage {}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final readonly class RenderImageMessage
|
||||
{
|
||||
public function __construct(
|
||||
public int $imageId,
|
||||
public string $deviceModel,
|
||||
public string $orientation,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class RunImageCleanupMessage {}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Message\AdvanceRotationMessage;
|
||||
use App\Service\RotationService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class AdvanceRotationMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RotationService $rotationService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(AdvanceRotationMessage $message): void
|
||||
{
|
||||
$devices = $this->em->getRepository(Device::class)->findAll();
|
||||
|
||||
foreach ($devices as $device) {
|
||||
if (!$this->isDue($device)) {
|
||||
$this->logger->debug('rotation.not_due', ['device_id' => $device->getId()]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$image = $this->rotationService->advance($device);
|
||||
if ($image === null) {
|
||||
$this->logger->info('rotation.no_ready_images', ['device_id' => $device->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isDue(Device $device): bool
|
||||
{
|
||||
if ($device->getWakeHour() !== null) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
$todayWake = $now->setTime($device->getWakeHour(), 0, 0);
|
||||
|
||||
if ($now < $todayWake) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Due if no history entry exists since wakeHour today
|
||||
$entry = $this->em->createQueryBuilder()
|
||||
->select('h')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->andWhere('h.servedAt >= :wakeTime')
|
||||
->setParameter('device', $device)
|
||||
->setParameter('wakeTime', $todayWake)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
return $entry === null;
|
||||
}
|
||||
|
||||
// Interval-based: due if last history is older than rotationIntervalMinutes
|
||||
$last = $this->em->createQueryBuilder()
|
||||
->select('h')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->orderBy('h.servedAt', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
if ($last === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$elapsed = (new \DateTimeImmutable())->getTimestamp() - $last->getServedAt()->getTimestamp();
|
||||
return $elapsed >= ($device->getRotationIntervalMinutes() * 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Message\RenderImageMessage;
|
||||
use App\Repository\ImageRepository;
|
||||
use App\Repository\RenderedAssetRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class RenderImageMessageHandler
|
||||
{
|
||||
// Waveshare Spectra 6 palette — must match gen_screens.py
|
||||
private const PALETTE = [
|
||||
0x0 => [26, 26, 26 ], // BLACK
|
||||
0x1 => [245, 245, 240], // WHITE
|
||||
0x2 => [240, 208, 0 ], // YELLOW
|
||||
0x3 => [192, 48, 32 ], // RED
|
||||
0x5 => [24, 64, 192], // BLUE
|
||||
0x6 => [16, 160, 64 ], // GREEN
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ImageRepository $imageRepo,
|
||||
private readonly RenderedAssetRepository $assetRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
public function __invoke(RenderImageMessage $msg): void
|
||||
{
|
||||
$image = $this->imageRepo->find($msg->imageId);
|
||||
if (!$image || $image->isDeleted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = DeviceModel::from($msg->deviceModel);
|
||||
$orientation = Orientation::from($msg->orientation);
|
||||
|
||||
$asset = $this->assetRepo->findOneBy([
|
||||
'image' => $image,
|
||||
'deviceModel' => $model,
|
||||
'orientation' => $orientation,
|
||||
]);
|
||||
if (!$asset) {
|
||||
$asset = (new RenderedAsset())
|
||||
->setImage($image)
|
||||
->setDeviceModel($model)
|
||||
->setOrientation($orientation);
|
||||
$this->em->persist($asset);
|
||||
}
|
||||
|
||||
$asset->setStatus(RenderStatus::Processing);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
// Prefer composited.jpg (cropped+stickered) over the raw original
|
||||
$compositedPath = $this->projectDir . '/var/storage/images/' . $image->getId() . '/composited.jpg';
|
||||
$originalPath = file_exists($compositedPath)
|
||||
? $compositedPath
|
||||
: $this->projectDir . '/' . $image->getStoragePath();
|
||||
$width = $model->width($orientation);
|
||||
$height = $model->height($orientation);
|
||||
$bin = $this->renderToBin($originalPath, $width, $height);
|
||||
|
||||
$relPath = 'var/storage/images/' . $image->getId()
|
||||
. '/' . $model->value . '_' . $orientation->value . '.bin';
|
||||
$absPath = $this->projectDir . '/' . $relPath;
|
||||
file_put_contents($absPath, $bin);
|
||||
|
||||
$asset->setFilePath($relPath)
|
||||
->setStatus(RenderStatus::Ready)
|
||||
->setRenderedAt(new \DateTimeImmutable());
|
||||
} catch (\Throwable) {
|
||||
$asset->setStatus(RenderStatus::Failed);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function renderToBin(string $path, int $width, int $height): string
|
||||
{
|
||||
$imagick = new \Imagick($path);
|
||||
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
|
||||
$imagick->setBackgroundColor('white');
|
||||
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
|
||||
$imagick->autoOrient();
|
||||
$imagick->cropThumbnailImage($width, $height);
|
||||
$imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB);
|
||||
|
||||
// Auto-levels: stretch the tonal range, clipping 1% at each end.
|
||||
// Fixes underexposed/dark photos so the full palette range is used.
|
||||
$pixels = $width * $height;
|
||||
$imagick->contrastStretchImage((int) ($pixels * 0.01), (int) ($pixels * 0.01));
|
||||
|
||||
// Boost saturation 130%. Dark desaturated photos otherwise map almost
|
||||
// entirely to BLACK with scattered noise dots from error diffusion.
|
||||
$imagick->modulateImage(100, 130, 100);
|
||||
|
||||
// Light sharpen so edges survive the dithering scatter.
|
||||
$imagick->sharpenImage(0, 0.8);
|
||||
|
||||
// Build a strip of 6 palette pixels for remapImage
|
||||
$palImagick = new \Imagick();
|
||||
foreach (self::PALETTE as $rgb) {
|
||||
$hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
||||
$tmp = new \Imagick();
|
||||
$tmp->newImage(1, 1, new \ImagickPixel($hex));
|
||||
$tmp->setImageFormat('png');
|
||||
$palImagick->addImage($tmp);
|
||||
$tmp->destroy();
|
||||
}
|
||||
$palImagick->resetIterator();
|
||||
$palStrip = $palImagick->appendImages(false);
|
||||
|
||||
$imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG);
|
||||
$palStrip->destroy();
|
||||
$palImagick->destroy();
|
||||
|
||||
// Export as raw RGB bytes
|
||||
$imagick->setImageDepth(8);
|
||||
$imagick->setFormat('RGB');
|
||||
$blob = $imagick->getImageBlob();
|
||||
$imagick->destroy();
|
||||
|
||||
// Pack into 4bpp: high nibble = left pixel, low nibble = right pixel
|
||||
$output = '';
|
||||
$total = $width * $height;
|
||||
for ($i = 0; $i < $total; $i += 2) {
|
||||
$base0 = $i * 3;
|
||||
$base1 = $base0 + 3;
|
||||
$n0 = $this->nearestPalette(ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2]));
|
||||
$n1 = $this->nearestPalette(ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2]));
|
||||
$output .= chr(($n0 << 4) | $n1);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function nearestPalette(int $r, int $g, int $b): int
|
||||
{
|
||||
$best = 0x1;
|
||||
$bestDist = PHP_INT_MAX;
|
||||
foreach (self::PALETTE as $index => $rgb) {
|
||||
$dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2;
|
||||
if ($dist < $bestDist) {
|
||||
$bestDist = $dist;
|
||||
$best = $index;
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\RunImageCleanupMessage;
|
||||
use App\Repository\ImageRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class RunImageCleanupMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImageRepository $imageRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
public function __invoke(RunImageCleanupMessage $message): void
|
||||
{
|
||||
$images = $this->imageRepository->findSoftDeleted();
|
||||
|
||||
foreach ($images as $image) {
|
||||
if ($image->getApprovedDevices()->count() > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$dir = $this->projectDir . '/var/storage/images/' . $image->getId();
|
||||
if (is_dir($dir)) {
|
||||
$this->deleteDirectory($dir);
|
||||
}
|
||||
$this->em->remove($image);
|
||||
$this->logger->info('Hard-deleted image {id}', ['id' => $image->getId()]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to hard-delete image {id}: {msg}', [
|
||||
'id' => $image->getId(),
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
foreach (new \FilesystemIterator($dir) as $item) {
|
||||
if ($item->isDir()) {
|
||||
$this->deleteDirectory($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<DeviceImageHistory>
|
||||
*/
|
||||
class DeviceImageHistoryRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DeviceImageHistory::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Image;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<Image> */
|
||||
class ImageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Image::class);
|
||||
}
|
||||
|
||||
/** @return Image[] */
|
||||
public function findSoftDeleted(): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.deletedAt IS NOT NULL')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\RenderedAsset;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<RenderedAsset> */
|
||||
class RenderedAssetRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, RenderedAsset::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SharedImage;
|
||||
use App\Entity\User;
|
||||
use App\Enum\SharedImageStatus;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<SharedImage> */
|
||||
class SharedImageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SharedImage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: SharedImage[], total: int}
|
||||
*/
|
||||
public function findForUser(User $user, ?SharedImageStatus $status, int $page, int $limit): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('s')
|
||||
->where('s.recipientUser = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('s.sharedAt', 'DESC');
|
||||
|
||||
if ($status !== null) {
|
||||
$qb->andWhere('s.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(s.id)')->resetDQLPart('orderBy')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return ['items' => $items, 'total' => (int) $total];
|
||||
}
|
||||
|
||||
public function pendingCountForUser(User $user): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
->where('s.recipientUser = :user')
|
||||
->andWhere('s.status = :status')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('status', SharedImageStatus::Pending)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Token;
|
||||
use App\Enum\TokenType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<Token> */
|
||||
class TokenRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Token::class);
|
||||
}
|
||||
|
||||
public function findValidToken(string $uuid, TokenType $type): ?Token
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->where('t.uuid = :uuid')
|
||||
->andWhere('t.type = :type')
|
||||
->andWhere('t.usedAt IS NULL')
|
||||
->andWhere('t.expiresAt > :now')
|
||||
->setParameter('uuid', $uuid)
|
||||
->setParameter('type', $type)
|
||||
->setParameter('now', new \DateTimeImmutable())
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Message\RunImageCleanupMessage;
|
||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||
use Symfony\Component\Scheduler\RecurringMessage;
|
||||
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -17,12 +19,14 @@ class Schedule implements ScheduleProviderInterface
|
||||
|
||||
public function getSchedule(): SymfonySchedule
|
||||
{
|
||||
// Rotation is handled at poll time in DeviceImageController — no scheduler needed.
|
||||
// DEV/PROD note: when switching to wakeHour mode, the device only polls once per day,
|
||||
// so rotation still happens correctly (isDue() fires on that single daily poll).
|
||||
return (new SymfonySchedule())
|
||||
->stateful($this->cache) // ensure missed tasks are executed
|
||||
->processOnlyLastMissedRun(true) // ensure only last missed task is run
|
||||
|
||||
// add your own tasks here
|
||||
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
|
||||
;
|
||||
->stateful($this->cache)
|
||||
->processOnlyLastMissedRun(true)
|
||||
->add(
|
||||
RecurringMessage::cron('0 * * * *', new RunImageCleanupMessage()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\User;
|
||||
use App\Repository\DeviceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -42,10 +44,29 @@ class DeviceService
|
||||
return $device;
|
||||
}
|
||||
|
||||
/** Remove all image approvals and rendered assets associated with this device. */
|
||||
/** Remove all image approvals and display history for this device. */
|
||||
private function purgeDeviceHistory(Device $device): void
|
||||
{
|
||||
// Stub: will cascade-delete via ORM relationships once Image/Approval entities are added in Epic 3.
|
||||
// Doctrine cascade on the Device→Approval relationship handles this automatically.
|
||||
// Remove device from every image's approval list
|
||||
$images = $this->em->createQueryBuilder()
|
||||
->select('i')
|
||||
->from(Image::class, 'i')
|
||||
->join('i.approvedDevices', 'd')
|
||||
->where('d = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
foreach ($images as $image) {
|
||||
$image->revokeForDevice($device);
|
||||
}
|
||||
|
||||
// Purge display history (no ORM cascade fires on ownership transfer)
|
||||
$this->em->createQueryBuilder()
|
||||
->delete(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Enum\RenderStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RotationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select the next image for the device, record history, update currentImage.
|
||||
* Returns null if no ready images exist in the pool.
|
||||
*/
|
||||
public function advance(Device $device): ?Image
|
||||
{
|
||||
$pool = $this->readyPool($device);
|
||||
|
||||
if (empty($pool)) {
|
||||
$this->logger->info('rotation.no_ready_images', ['device_id' => $device->getId()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$window = min($device->getUniquenessWindow(), count($pool));
|
||||
$recentIds = $this->recentImageIds($device, $window);
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$pool,
|
||||
static fn(Image $i) => !in_array($i->getId(), $recentIds, true),
|
||||
));
|
||||
|
||||
if (empty($candidates)) {
|
||||
$candidates = $pool;
|
||||
}
|
||||
|
||||
usort($candidates, static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt());
|
||||
|
||||
$image = $candidates[0];
|
||||
|
||||
$this->em->persist(new DeviceImageHistory($device, $image));
|
||||
$device->setCurrentImage($image);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->info('rotation.advanced', [
|
||||
'device_id' => $device->getId(),
|
||||
'image_id' => $image->getId(),
|
||||
'pool_size' => count($pool),
|
||||
'recent_ids' => $recentIds,
|
||||
]);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/** @return Image[] */
|
||||
private function readyPool(Device $device): array
|
||||
{
|
||||
return $this->em->createQueryBuilder()
|
||||
->select('i')
|
||||
->from(Image::class, 'i')
|
||||
->join('i.approvedDevices', 'd')
|
||||
->join('i.renderedAssets', 'ra')
|
||||
->where('d = :device')
|
||||
->andWhere('i.deletedAt IS NULL')
|
||||
->andWhere('ra.deviceModel = :model')
|
||||
->andWhere('ra.orientation = :orientation')
|
||||
->andWhere('ra.status = :status')
|
||||
->setParameter('device', $device)
|
||||
->setParameter('model', $device->getModel())
|
||||
->setParameter('orientation', $device->getOrientation())
|
||||
->setParameter('status', RenderStatus::Ready)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function recentImageIds(Device $device, int $limit): array
|
||||
{
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select('IDENTITY(h.image) AS image_id')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->orderBy('h.servedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_column($rows, 'image_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Image;
|
||||
use App\Entity\SharedImage;
|
||||
use App\Entity\User;
|
||||
use App\Enum\TokenType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly MailerInterface $mailer,
|
||||
#[Autowire('%env(int:SHARE_TOKEN_TTL_DAYS)%')]
|
||||
private readonly int $tokenTtlDays,
|
||||
#[Autowire('%env(MAILER_SENDER)%')]
|
||||
private readonly string $mailerSender,
|
||||
) {}
|
||||
|
||||
public function share(Image $image, User $sharer, string $recipientEmail): SharedImage
|
||||
{
|
||||
$recipient = $this->em->getRepository(User::class)->findOneBy(['email' => $recipientEmail]);
|
||||
|
||||
if ($recipient === null) {
|
||||
throw new \InvalidArgumentException('Recipient not found. They must have an account to receive shared photos.');
|
||||
}
|
||||
|
||||
if ($recipient->getId() === $sharer->getId()) {
|
||||
throw new \InvalidArgumentException('You cannot share a photo with yourself.');
|
||||
}
|
||||
|
||||
// Idempotent: return existing pending share if one already exists
|
||||
$existing = $this->em->getRepository(SharedImage::class)->findOneBy([
|
||||
'sourceImage' => $image,
|
||||
'recipientUser' => $recipient,
|
||||
]);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$shared = new SharedImage($image, $recipient, $sharer);
|
||||
$this->em->persist($shared);
|
||||
|
||||
$approveToken = $this->tokenService->issue(TokenType::ShareApprove, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
|
||||
$declineToken = $this->tokenService->issue(TokenType::ShareDecline, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$email = (new TemplatedEmail())
|
||||
->from($this->mailerSender)
|
||||
->to($recipientEmail)
|
||||
->subject($sharer->getEmail() . ' shared a photo with you')
|
||||
->htmlTemplate('emails/share_notification.html.twig')
|
||||
->textTemplate('emails/share_notification.txt.twig')
|
||||
->context([
|
||||
'sharer' => $sharer,
|
||||
'image' => $image,
|
||||
'approveToken' => $approveToken,
|
||||
'declineToken' => $declineToken,
|
||||
]);
|
||||
|
||||
$this->mailer->send($email);
|
||||
|
||||
return $shared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Image;
|
||||
use App\Entity\Token;
|
||||
use App\Entity\User;
|
||||
use App\Enum\TokenType;
|
||||
use App\Repository\TokenRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TokenRepository $tokenRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function issue(
|
||||
TokenType $type,
|
||||
Image $image,
|
||||
?User $recipient,
|
||||
?string $recipientEmail,
|
||||
int $ttlDays,
|
||||
): Token {
|
||||
$token = new Token($type, $image, $recipient, $recipientEmail, $ttlDays);
|
||||
$this->em->persist($token);
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** @throws \RuntimeException if token is not found, already used, or expired */
|
||||
public function consume(string $uuid, TokenType $type): Token
|
||||
{
|
||||
$token = $this->tokenRepository->findValidToken($uuid, $type);
|
||||
if (!$token) {
|
||||
throw new \RuntimeException('Token not found, already used, or expired.');
|
||||
}
|
||||
$token->consume();
|
||||
$this->em->flush();
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Returns a valid token without consuming it, or null. */
|
||||
public function findValid(string $uuid, TokenType $type): ?Token
|
||||
{
|
||||
return $this->tokenRepository->findValidToken($uuid, $type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user