chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

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:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -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,
];
}
}
+77 -9
View File
@@ -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(),
];
}
}
+133
View File
@@ -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;
}
}
+340
View File
@@ -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();
}
}
+2 -2
View File
@@ -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();
+5 -4
View File
@@ -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');
}
}
+52
View File
@@ -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]);
}
}